Skip to main content

evm_fork_cache/reactive/
mod.rs

1//! Protocol-neutral reactive runtime for cache state effects.
2//!
3//! The reactive runtime generalizes the log-only [`events`](crate::events)
4//! pipeline into a handler pipeline that can ingest logs, block notifications,
5//! and pending transaction signals. Handlers remain pure synchronous functions:
6//! they read through [`StateView`], return structured
7//! [`ReactiveEffect`] values, and let the runtime validate and commit cache
8//! mutations through [`StateUpdate`].
9//!
10//! This module intentionally contains no protocol, AMM, strategy, signing, or
11//! transaction-submission concepts. Downstream crates can layer those domains on
12//! top by implementing [`ReactiveHandler`] and [`ReactiveHook`].
13
14use std::{
15    any::Any,
16    borrow::Cow,
17    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    num::NonZeroU64,
23    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, BlockNumberOrTag};
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::{
46    future::{Either, poll_fn, select, try_join_all},
47    stream::BoxStream,
48};
49
50use crate::{
51    cache::{AccountProof, BlockStateDiff, EvmCache},
52    errors::{BlockContextError, StorageFetchResult},
53    events::{EventDecoder, StateView},
54    freshness::FreshnessRegistry,
55    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
56};
57
58/// Input accepted by the reactive runtime.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum ReactiveInput<N: Network = Ethereum> {
61    /// A canonical or removed EVM log, using Alloy's RPC log type.
62    Log(Log),
63    /// A block header response for header-oriented handlers.
64    BlockHeader(N::HeaderResponse),
65    /// A full block response for block handlers that need transaction bodies.
66    FullBlock(N::BlockResponse),
67    /// A pending transaction hash.
68    PendingTxHash(B256),
69    /// A full pending transaction body.
70    PendingTx(N::TransactionResponse),
71}
72
73/// Context supplied with each [`ReactiveInput`].
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ReactiveContext {
76    /// Chain id, when known.
77    pub chain_id: Option<u64>,
78    /// Where the input came from.
79    pub source: InputSource,
80    /// Lifecycle status of the input.
81    pub chain_status: ChainStatus,
82    /// Block metadata associated with the input, when known.
83    pub block: Option<BlockRef>,
84    /// Transaction index for log or transaction inputs.
85    pub transaction_index: Option<u64>,
86    /// Log index for log inputs.
87    pub log_index: Option<u64>,
88}
89
90/// Minimal block identity carried through reports.
91#[derive(Clone, Debug, PartialEq, Eq, Hash)]
92pub struct BlockRef {
93    /// Block number.
94    pub number: u64,
95    /// Block hash.
96    pub hash: B256,
97    /// Parent hash, when known.
98    pub parent_hash: Option<B256>,
99    /// Block timestamp, when known.
100    pub timestamp: Option<u64>,
101}
102
103/// Lifecycle status for an input.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum ChainStatus {
106    /// The input is mempool-only and must not mutate canonical cache state.
107    Pending,
108    /// The input is included in a block with a confirmation count.
109    Included {
110        /// Included block.
111        block: BlockRef,
112        /// Confirmation count.
113        confirmations: u64,
114    },
115    /// The input is in the chain's safe head.
116    Safe {
117        /// Safe block.
118        block: BlockRef,
119    },
120    /// The input is in the finalized head.
121    Finalized {
122        /// Finalized block.
123        block: BlockRef,
124    },
125    /// The input was dropped by a reorg.
126    Reorged {
127        /// Block the input was dropped from.
128        dropped_from: BlockRef,
129    },
130}
131
132/// Source of an input batch.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
134pub enum InputSource {
135    /// Caller-supplied batch.
136    Batch,
137    /// Live subscription stream.
138    Subscription,
139    /// Polling subscriber.
140    Poll,
141    /// Historical backfill.
142    Backfill,
143    /// Test or synthetic input.
144    Synthetic,
145}
146
147/// Stable identity used for input deduplication and reports.
148#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
149pub enum InputRef {
150    /// Stable log identity.
151    Log {
152        /// Chain id, when known.
153        chain_id: Option<u64>,
154        /// Block hash containing the log.
155        block_hash: B256,
156        /// Transaction hash that emitted the log.
157        transaction_hash: B256,
158        /// Log index within the block.
159        log_index: u64,
160    },
161    /// Stable pending transaction identity.
162    PendingTx {
163        /// Chain id, when known.
164        chain_id: Option<u64>,
165        /// Transaction hash.
166        hash: B256,
167    },
168    /// Stable block identity.
169    Block {
170        /// Chain id, when known.
171        chain_id: Option<u64>,
172        /// Block hash.
173        hash: B256,
174        /// Block number.
175        number: u64,
176    },
177}
178
179/// Reliability of state effects emitted by a handler.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
181pub enum StateEffectQuality {
182    /// Effects are exact from the input alone.
183    ExactFromInput,
184    /// Effects were applied, but follow-up resync is pending.
185    AppliedWithPendingResync,
186    /// Effects came from authoritative resync.
187    ResyncedAuthoritatively,
188    /// State requires repair before it should be trusted.
189    RequiresRepair,
190    /// No canonical state effect was emitted.
191    NoStateEffect,
192}
193
194/// Identifier for a reactive handler.
195#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
196pub struct HandlerId(String);
197
198impl HandlerId {
199    /// Create a handler id.
200    pub fn new(id: impl Into<String>) -> Self {
201        Self(id.into())
202    }
203
204    /// Return the id as a string slice.
205    pub fn as_str(&self) -> &str {
206        &self.0
207    }
208}
209
210impl fmt::Display for HandlerId {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        self.0.fmt(f)
213    }
214}
215
216/// Lightweight report label.
217#[derive(Clone, Debug, PartialEq, Eq, Hash)]
218pub struct ReportTag {
219    /// Label key.
220    pub key: String,
221    /// Label value.
222    pub value: String,
223}
224
225impl ReportTag {
226    /// Create a report tag.
227    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
228        Self {
229            key: key.into(),
230            value: value.into(),
231        }
232    }
233}
234
235/// Domain-neutral hook signal emitted by a handler.
236#[derive(Clone)]
237pub struct HookSignal {
238    /// Signal namespace owned by the caller.
239    pub namespace: Cow<'static, str>,
240    /// Signal kind within the namespace.
241    pub kind: Cow<'static, str>,
242    /// Additional labels for routing or observability.
243    pub labels: Vec<ReportTag>,
244    /// Optional in-process typed payload.
245    pub payload: Option<Arc<dyn Any + Send + Sync>>,
246}
247
248impl fmt::Debug for HookSignal {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.debug_struct("HookSignal")
251            .field("namespace", &self.namespace)
252            .field("kind", &self.kind)
253            .field("labels", &self.labels)
254            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
255            .finish()
256    }
257}
258
259/// Effect emitted by a [`ReactiveHandler`].
260#[derive(Clone, Debug)]
261pub enum ReactiveEffect {
262    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
263    StateUpdate(StateUpdate),
264    /// Request for authoritative state repair.
265    Resync(ResyncRequest),
266    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
267    Invalidate(InvalidationRequest),
268    /// Hook signal dispatched after committed mutation phases.
269    Hook(HookSignal),
270    /// Speculative signal for mempool or downstream work.
271    Speculative(SpeculativeRequest),
272}
273
274/// Handler output for a single input.
275#[derive(Clone, Debug)]
276pub struct HandlerOutcome {
277    /// Effects emitted by the handler.
278    pub effects: Vec<ReactiveEffect>,
279    /// Reliability of emitted state effects.
280    pub quality: StateEffectQuality,
281    /// Labels copied into reports.
282    pub tags: Vec<ReportTag>,
283}
284
285impl HandlerOutcome {
286    /// Construct an empty outcome with the supplied quality.
287    pub fn empty(quality: StateEffectQuality) -> Self {
288        Self {
289            effects: Vec::new(),
290            quality,
291            tags: Vec::new(),
292        }
293    }
294}
295
296/// One input and its execution context.
297#[derive(Clone, Debug)]
298pub struct ReactiveInputRecord<N: Network = Ethereum> {
299    /// Input value.
300    pub input: ReactiveInput<N>,
301    /// Input context.
302    pub context: ReactiveContext,
303}
304
305impl<N: Network> ReactiveInputRecord<N> {
306    /// Create an input record.
307    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
308        Self { input, context }
309    }
310
311    /// Compute the stable input reference used for deduplication.
312    pub fn input_ref(&self) -> InputRef {
313        input_ref(&self.input, &self.context)
314    }
315}
316
317/// Batch of reactive input records.
318#[derive(Clone, Debug)]
319pub struct ReactiveInputBatch<N: Network = Ethereum> {
320    records: Vec<ReactiveInputRecord<N>>,
321}
322
323impl<N: Network> ReactiveInputBatch<N> {
324    /// Create a batch from records.
325    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
326        Self { records }
327    }
328
329    /// Borrow the records in this batch.
330    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
331        &self.records
332    }
333
334    /// Consume the batch into its records.
335    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
336        self.records
337    }
338}
339
340/// Pure synchronous handler for reactive inputs.
341pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
342    /// Stable handler id.
343    fn id(&self) -> HandlerId;
344
345    /// Interests used by subscribers and the local router.
346    fn interests(&self) -> Vec<ReactiveInterest<N>>;
347
348    /// Exhaustive exact keys for log inputs this handler can accept.
349    ///
350    /// Returning `None` keeps the handler on the compatibility fallback path.
351    /// Returning an index promises that every matching log has at least one of
352    /// its keys; the registry still re-checks the handler's original
353    /// [`LogInterest`]s and local matchers before dispatch.
354    fn log_route_index(&self) -> Option<LogRouteIndex> {
355        None
356    }
357
358    /// Handle one input against a read-only cache view.
359    fn handle(
360        &self,
361        ctx: &ReactiveContext,
362        input: &ReactiveInput<N>,
363        state: &dyn StateView,
364    ) -> Result<HandlerOutcome, HandlerError>;
365}
366
367/// Hook invoked after reports are built and cache mutation phases have ended.
368pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
369    /// Observe a runtime report.
370    fn on_report(&self, report: Arc<ReactiveReport<N>>);
371}
372
373/// Reactive subscription interest.
374#[allow(clippy::large_enum_variant)]
375#[derive(Clone)]
376pub enum ReactiveInterest<N: Network = Ethereum> {
377    /// Log interest.
378    Logs(LogInterest),
379    /// Block interest.
380    Blocks(BlockInterest),
381    /// Pending transaction interest.
382    PendingTransactions(PendingTxInterest<N>),
383}
384
385impl<N: Network> fmt::Debug for ReactiveInterest<N> {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        match self {
388            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
389            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
390            Self::PendingTransactions(interest) => f
391                .debug_tuple("PendingTransactions")
392                .field(interest)
393                .finish(),
394        }
395    }
396}
397
398/// Interest in logs.
399#[derive(Clone)]
400pub struct LogInterest {
401    /// Provider-side filter.
402    pub provider_filter: Filter,
403    /// Optional local matcher for predicates providers cannot express.
404    pub local_matcher: Option<Arc<dyn LogMatcher>>,
405    /// Optional route-key extraction strategy.
406    pub route_key: Option<RouteKeySpec>,
407}
408
409impl LogInterest {
410    /// Return true if the log matches both the provider filter and local matcher.
411    pub fn matches(&self, log: &Log) -> bool {
412        self.provider_filter.rpc_matches(log)
413            && self
414                .local_matcher
415                .as_ref()
416                .is_none_or(|matcher| matcher.matches(log))
417    }
418
419    /// Extract the route key for a matching log, if configured.
420    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
421        self.route_key.as_ref().and_then(|spec| spec.extract(log))
422    }
423}
424
425impl fmt::Debug for LogInterest {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        f.debug_struct("LogInterest")
428            .field("provider_filter", &self.provider_filter)
429            .field(
430                "local_matcher",
431                &self.local_matcher.as_ref().map(|_| "<matcher>"),
432            )
433            .field("route_key", &self.route_key)
434            .finish()
435    }
436}
437
438/// Local log predicate.
439pub trait LogMatcher: Send + Sync {
440    /// Return true when the log should be routed to the handler.
441    fn matches(&self, log: &Log) -> bool;
442}
443
444/// Route-key extraction strategy for logs.
445#[derive(Clone)]
446pub enum RouteKeySpec {
447    /// Route by emitting address.
448    EmitterAddress,
449    /// Route by indexed topic.
450    Topic {
451        /// Topic index.
452        index: usize,
453    },
454    /// Route by a byte slice in log data.
455    DataSlice {
456        /// Byte offset in the data payload.
457        offset: usize,
458        /// Number of bytes to copy.
459        len: usize,
460    },
461    /// Custom extractor.
462    Custom(Arc<dyn RouteKeyExtractor>),
463}
464
465impl RouteKeySpec {
466    /// Extract a route key from a log.
467    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
468        match self {
469            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
470            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
471            Self::DataSlice { offset, len } => {
472                let data = log.inner.data.data.as_ref();
473                let end = offset.checked_add(*len)?;
474                data.get(*offset..end)
475                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
476            }
477            Self::Custom(extractor) => extractor.extract(log),
478        }
479    }
480}
481
482impl fmt::Debug for RouteKeySpec {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        match self {
485            Self::EmitterAddress => f.write_str("EmitterAddress"),
486            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
487            Self::DataSlice { offset, len } => f
488                .debug_struct("DataSlice")
489                .field("offset", offset)
490                .field("len", len)
491                .finish(),
492            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
493        }
494    }
495}
496
497/// Extracts custom route keys from logs.
498pub trait RouteKeyExtractor: Send + Sync {
499    /// Extract a route key.
500    fn extract(&self, log: &Log) -> Option<RouteKey>;
501}
502
503/// Extracted route key.
504#[derive(Clone, Debug, PartialEq, Eq, Hash)]
505pub enum RouteKey {
506    /// Address key.
507    Address(Address),
508    /// 32-byte key.
509    Bytes32(B256),
510    /// Arbitrary bytes key.
511    Bytes(Vec<u8>),
512}
513
514/// Exact protocol-neutral key used to select candidate log handlers.
515#[non_exhaustive]
516#[derive(Clone, Debug, PartialEq, Eq, Hash)]
517pub enum LogRouteKey {
518    /// Emitting contract address.
519    Emitter(Address),
520    /// Exact indexed topic.
521    Topic {
522        /// Topic position in the log.
523        index: usize,
524        /// Expected topic value.
525        value: B256,
526    },
527    /// Exact byte slice in the log data.
528    DataSlice {
529        /// Byte offset in the data payload.
530        offset: usize,
531        /// Expected bytes.
532        value: Vec<u8>,
533    },
534}
535
536/// Non-empty exhaustive OR-set of exact log route keys.
537#[derive(Clone, Debug, PartialEq, Eq)]
538pub struct LogRouteIndex {
539    keys: Vec<LogRouteKey>,
540}
541
542impl LogRouteIndex {
543    /// Construct an index from one required key and optional additional keys.
544    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
545        let mut keys = vec![primary];
546        for key in additional {
547            if !keys.contains(&key) {
548                keys.push(key);
549            }
550        }
551        Self { keys }
552    }
553
554    /// Construct a single-key index.
555    pub fn single(key: LogRouteKey) -> Self {
556        Self { keys: vec![key] }
557    }
558
559    /// Exact keys in declaration order.
560    pub fn keys(&self) -> &[LogRouteKey] {
561        &self.keys
562    }
563}
564
565/// Exact log route selected by [`ReactiveRegistry::route_log`].
566#[derive(Clone, Debug, PartialEq, Eq)]
567pub struct ReactiveLogRoute {
568    /// Handler whose log interest matched.
569    pub handler_id: HandlerId,
570    /// Optional route key extracted from the matching log interest.
571    pub route_key: Option<RouteKey>,
572}
573
574/// Interest in block inputs.
575#[derive(Clone, Debug, PartialEq, Eq, Hash)]
576pub struct BlockInterest {
577    /// Block input mode.
578    pub mode: BlockInterestMode,
579}
580
581impl Default for BlockInterest {
582    fn default() -> Self {
583        Self {
584            mode: BlockInterestMode::Header,
585        }
586    }
587}
588
589/// Block subscription mode.
590#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
591pub enum BlockInterestMode {
592    /// Header-only block input.
593    Header,
594    /// Full block input.
595    FullBlock,
596}
597
598/// Interest in pending transaction inputs.
599#[derive(Clone)]
600pub struct PendingTxInterest<N: Network = Ethereum> {
601    /// Whether the handler requires full transaction bodies.
602    pub full_transactions: bool,
603    /// Sender matcher.
604    pub from: AddressMatcher,
605    /// Recipient matcher.
606    pub to: AddressMatcher,
607    /// Calldata selector matcher.
608    pub selectors: SelectorMatcher,
609    /// Optional local transaction matcher.
610    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
611}
612
613impl<N: Network> Default for PendingTxInterest<N> {
614    fn default() -> Self {
615        Self {
616            full_transactions: false,
617            from: AddressMatcher::Any,
618            to: AddressMatcher::Any,
619            selectors: SelectorMatcher::Any,
620            local_matcher: None,
621        }
622    }
623}
624
625impl<N: Network> PendingTxInterest<N> {
626    fn matches_hash_only(&self) -> bool {
627        !self.full_transactions
628            && self.from.is_any()
629            && self.to.is_any()
630            && self.selectors.is_any()
631            && self.local_matcher.is_none()
632    }
633
634    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
635        self.from.matches(tx.from())
636            && self.to.matches_option(tx.to())
637            && self.selectors.matches(tx.input())
638            && self
639                .local_matcher
640                .as_ref()
641                .is_none_or(|matcher| matcher.matches(tx))
642    }
643}
644
645impl<N: Network> fmt::Debug for PendingTxInterest<N> {
646    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647        f.debug_struct("PendingTxInterest")
648            .field("full_transactions", &self.full_transactions)
649            .field("from", &self.from)
650            .field("to", &self.to)
651            .field("selectors", &self.selectors)
652            .field(
653                "local_matcher",
654                &self.local_matcher.as_ref().map(|_| "<matcher>"),
655            )
656            .finish()
657    }
658}
659
660/// Address matching helper for pending transaction interests.
661#[derive(Clone, Debug, PartialEq, Eq, Hash)]
662pub enum AddressMatcher {
663    /// Match every address.
664    Any,
665    /// Match one address.
666    Exact(Address),
667    /// Match any address in the list.
668    AnyOf(Vec<Address>),
669}
670
671impl AddressMatcher {
672    /// Return true when the matcher is unconstrained.
673    pub fn is_any(&self) -> bool {
674        matches!(self, Self::Any)
675    }
676
677    /// Match a present address.
678    pub fn matches(&self, address: Address) -> bool {
679        match self {
680            Self::Any => true,
681            Self::Exact(expected) => *expected == address,
682            Self::AnyOf(addresses) => addresses.contains(&address),
683        }
684    }
685
686    /// Match an optional address.
687    pub fn matches_option(&self, address: Option<Address>) -> bool {
688        match (self, address) {
689            (Self::Any, _) => true,
690            (_, Some(address)) => self.matches(address),
691            _ => false,
692        }
693    }
694}
695
696/// Calldata selector matching helper.
697#[derive(Clone, Debug, PartialEq, Eq, Hash)]
698pub enum SelectorMatcher {
699    /// Match every selector.
700    Any,
701    /// Match any selector in the list.
702    AnyOf(Vec<[u8; 4]>),
703}
704
705impl SelectorMatcher {
706    /// Return true when the matcher is unconstrained.
707    pub fn is_any(&self) -> bool {
708        matches!(self, Self::Any)
709    }
710
711    /// Match calldata bytes.
712    pub fn matches(&self, input: &Bytes) -> bool {
713        match self {
714            Self::Any => true,
715            Self::AnyOf(selectors) => input
716                .get(..4)
717                .and_then(|bytes| bytes.try_into().ok())
718                .is_some_and(|selector| selectors.contains(&selector)),
719        }
720    }
721}
722
723/// Local predicate over a full pending transaction.
724pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
725    /// Return true when the transaction should be routed to the handler.
726    fn matches(&self, tx: &N::TransactionResponse) -> bool;
727}
728
729/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
730///
731/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
732/// liveness strategy is per-contract:
733///
734/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
735///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
736///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
737/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
738///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
739///   each canonical block; a move a decoder did not cover is a coverage gap.
740///
741/// A false-positive resync is never *incorrect* — it costs one batched read — so
742/// the policy is a **pure cost knob**, not a correctness lever.
743///
744/// [`Slots`]: TrackingPolicy::Slots
745/// [`WholeAccount`]: TrackingPolicy::WholeAccount
746#[derive(Clone, Debug)]
747#[non_exhaustive]
748pub enum TrackingPolicy {
749    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
750    /// nearly every block, so it is a noisy gate — this policy is **never**
751    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
752    /// and cadence reconcile.
753    Slots {
754        /// The enumerated storage slots of interest.
755        slots: Vec<U256>,
756    },
757    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
758    /// the root is a tight, cheap gate: probe each canonical block; on a move no
759    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
760    /// [`ResyncReason::RootMoved`] repair.
761    WholeAccount,
762    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
763    /// response's account fields; no storage interest. Native balance/nonce
764    /// changes do **not** move the storage root, so this policy compares the
765    /// account fields directly across blocks rather than root-gating.
766    Scalars,
767}
768
769/// How often the reactive root gate probes tracked accounts
770/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
771/// `Scalars` account-fields comparison rides the same firing).
772///
773/// `eth_getProof` is the slowest read this crate issues, so per-block probing
774/// is never the default. Skipping blocks is safe by construction: the gate
775/// diffs `root_now` against its **persisted baseline**, never
776/// block-over-block, so a move in any skipped block is still visible at the
777/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
778/// cost, never eventual detection. The decoder-touched set accumulates across
779/// skipped blocks and drains per firing, so a covered write in a skipped
780/// block never false-positives as a [`ReactiveReport::CoverageGap`].
781#[derive(Clone, Copy, Debug, PartialEq, Eq)]
782pub enum RootGateCadence {
783    /// Probe at most once every `n` canonical blocks (the first canonical
784    /// block ever seen always fires, so baseline adoption does not wait a
785    /// full window). `EveryNBlocks(1)` is per-block probing.
786    EveryNBlocks(NonZeroU64),
787    /// Root gate off: coverage gaps surface only via decoders + freshness.
788    Disabled,
789}
790
791impl RootGateCadence {
792    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
793    pub fn every_n_blocks(n: u64) -> Self {
794        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
795    }
796}
797
798impl Default for RootGateCadence {
799    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
800    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
801    /// `n`, not lower it.
802    fn default() -> Self {
803        Self::every_n_blocks(16)
804    }
805}
806
807/// Per-account baseline held by the root gate: the last observed on-chain root
808/// and account fields, plus the block they were observed at.
809///
810/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
811/// spec §6): it persists the *observed* root as a baseline and compares
812/// `root_now` to it. This is a currency gate, not a completeness gate.
813#[derive(Clone, Debug)]
814struct TrackedRoot {
815    last_root: B256,
816    last_block: u64,
817    balance: U256,
818    nonce: u64,
819    code_hash: B256,
820}
821
822/// Request for authoritative state repair.
823#[derive(Clone, Debug, PartialEq, Eq)]
824pub struct ResyncRequest {
825    /// Resync id.
826    pub id: ResyncId,
827    /// Reason for the request.
828    pub reason: ResyncReason,
829    /// Block selection for the read.
830    pub block: ResyncBlock,
831    /// Targets to resync.
832    pub targets: Vec<ResyncTarget>,
833    /// Scheduling priority.
834    pub priority: ResyncPriority,
835}
836
837/// Resync id.
838#[derive(Clone, Debug, PartialEq, Eq, Hash)]
839pub struct ResyncId(String);
840
841impl ResyncId {
842    /// Create a resync id.
843    pub fn new(id: impl Into<String>) -> Self {
844        Self(id.into())
845    }
846}
847
848/// Reason for a resync request.
849#[derive(Clone, Debug, PartialEq, Eq, Hash)]
850#[non_exhaustive]
851pub enum ResyncReason {
852    /// Handler requested repair.
853    HandlerRequested,
854    /// State effect could not be applied completely.
855    SkippedStateEffect,
856    /// A missed block range was detected; caller-scheduled repair.
857    ///
858    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
859    /// range (there are no known targets to resync). This reason is provided so a
860    /// caller building its own repair in response to a
861    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
862    MissedBlockRange,
863    /// A tracked account's storage root moved with no covering decoder.
864    ///
865    /// Emitted by the per-block root gate (Phase-8 step 4). A
866    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
867    /// `storageHash` moved between the adopted baseline and the current canonical
868    /// block, yet no decoder wrote that account during the block — a coverage gap.
869    /// The gate schedules a resync with this reason to re-read the account
870    /// authoritatively and self-heal the blind spot. Also used for the
871    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
872    RootMoved,
873    /// Caller-defined reason.
874    Custom(String),
875}
876
877/// Block target for a resync.
878#[derive(Clone, Debug, PartialEq, Eq, Hash)]
879pub enum ResyncBlock {
880    /// Latest block.
881    Latest,
882    /// Safe head.
883    Safe,
884    /// Finalized head.
885    Finalized,
886    /// Block number.
887    Number(u64),
888    /// Block hash and number.
889    Hash {
890        /// Block number.
891        number: u64,
892        /// Block hash.
893        hash: B256,
894        /// Require the hash to still be canonical.
895        require_canonical: bool,
896    },
897}
898
899/// State target for a resync.
900#[derive(Clone, Debug, PartialEq, Eq, Hash)]
901pub enum ResyncTarget {
902    /// One storage slot.
903    StorageSlot {
904        /// Contract address.
905        address: Address,
906        /// Storage slot.
907        slot: U256,
908    },
909    /// Multiple storage slots on one contract.
910    StorageSlots {
911        /// Contract address.
912        address: Address,
913        /// Storage slots.
914        slots: Vec<U256>,
915    },
916    /// Account fields.
917    Account {
918        /// Account address.
919        address: Address,
920        /// Fields to resync.
921        fields: AccountFieldMask,
922    },
923}
924
925/// Account fields requested by a resync.
926#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
927pub struct AccountFieldMask {
928    /// Balance field.
929    pub balance: bool,
930    /// Nonce field.
931    pub nonce: bool,
932    /// Code field.
933    pub code: bool,
934}
935
936/// Resync priority.
937#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
938pub enum ResyncPriority {
939    /// Low priority.
940    Low,
941    /// Normal priority.
942    #[default]
943    Normal,
944    /// High priority.
945    High,
946}
947
948/// Rich invalidation request lowered to [`StateUpdate::Purge`].
949#[derive(Clone, Debug, PartialEq, Eq)]
950pub struct InvalidationRequest {
951    /// Purge scope.
952    pub scope: PurgeScope,
953    /// Address to purge.
954    pub address: Address,
955    /// Reason for reporting.
956    pub reason: InvalidationReason,
957}
958
959/// Invalidation reason.
960#[derive(Clone, Debug, PartialEq, Eq, Hash)]
961pub enum InvalidationReason {
962    /// Handler requested invalidation.
963    HandlerRequested,
964    /// Reorg invalidation.
965    Reorg,
966    /// Caller-defined reason.
967    Custom(String),
968}
969
970/// Speculative signal emitted by handlers.
971#[derive(Clone, Debug, PartialEq, Eq)]
972pub struct SpeculativeRequest {
973    /// Speculative request id.
974    pub id: SpeculativeId,
975    /// Input that triggered the request.
976    pub input_ref: InputRef,
977    /// Labels for downstream routing.
978    pub labels: Vec<ReportTag>,
979}
980
981/// Speculative request id.
982#[derive(Clone, Debug, PartialEq, Eq, Hash)]
983pub struct SpeculativeId(String);
984
985impl SpeculativeId {
986    /// Create a speculative id.
987    pub fn new(id: impl Into<String>) -> Self {
988        Self(id.into())
989    }
990}
991
992/// Configuration for [`ReactiveRuntime`].
993#[derive(Clone, Debug, PartialEq, Eq)]
994pub struct ReactiveConfig {
995    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
996    /// dispatch is synchronous today (every report is delivered to every hook in
997    /// order), so this field is a no-op placeholder for a future async dispatcher.
998    /// Setting it to anything other than the default does not change behavior.
999    pub hook_backpressure: HookBackpressure,
1000    /// Reorg journal depth: the number of recent canonical blocks whose effects
1001    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
1002    /// only blocks still resident in the journal can be recovered. A reorg deeper
1003    /// than `journal_depth` recovers the blocks still in the journal and leaves
1004    /// the aged-out blocks' effects in place — they are **neither rolled back nor
1005    /// purged**, so the freshness/validation loop is the only backstop for that
1006    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
1007    ///
1008    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
1009    /// precisely. When a reorg references a block that is no longer in the journal,
1010    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
1011    /// rather than silent.
1012    pub journal_depth: usize,
1013}
1014
1015impl Default for ReactiveConfig {
1016    fn default() -> Self {
1017        Self {
1018            hook_backpressure: HookBackpressure::Block,
1019            journal_depth: 64,
1020        }
1021    }
1022}
1023
1024/// Hook backpressure policy.
1025#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1026pub enum HookBackpressure {
1027    /// Block the producer until hooks are accepted.
1028    Block,
1029    /// Drop the newest report under pressure.
1030    DropNewest,
1031    /// Drop the oldest report under pressure.
1032    DropOldest,
1033    /// Return an error under pressure.
1034    Error,
1035}
1036
1037/// Queryable coarse health of the reactive cache.
1038///
1039/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
1040/// degraded or unhealthy state when it detects that its recovery guarantees no
1041/// longer hold (for example a reorg that runs deeper than the journal, so some
1042/// dropped effects are neither rolled back nor purged). Later waves report
1043/// missed-range and coverage-gap conditions into the same state machine.
1044#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1045#[non_exhaustive]
1046pub enum CacheHealth {
1047    /// All recovery guarantees hold; the cache is fully self-consistent.
1048    #[default]
1049    Healthy,
1050    /// A recoverable inconsistency was detected (for example under-recovered
1051    /// reorg effects); `since_block` records the block that triggered the
1052    /// transition.
1053    Degraded {
1054        /// Block number at which the degradation was first observed.
1055        since_block: u64,
1056    },
1057    /// A more serious inconsistency was detected; `since_block` records the
1058    /// block that triggered the transition.
1059    Unhealthy {
1060        /// Block number at which the unhealthy condition was first observed.
1061        since_block: u64,
1062    },
1063}
1064
1065/// Point-in-time copy of the reactive runtime's observability counters.
1066///
1067/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
1068/// increasing count over the lifetime of the runtime. Counters wired by later
1069/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
1070/// tracking) remain zero until those waves land.
1071#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1072#[non_exhaustive]
1073pub struct CacheMetricsSnapshot {
1074    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
1075    /// rolled back or purged.
1076    pub deep_reorgs: u64,
1077    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
1078    pub reorgs_recovered: u64,
1079    /// Storage resync targets considered by the resync execution pass.
1080    pub resync_requests: u64,
1081    /// Storage resync targets that could not be fetched or applied.
1082    pub resync_failures: u64,
1083    /// Ranges of blocks the runtime detected it did not observe (reserved).
1084    pub missed_ranges: u64,
1085    /// Storage-hash coverage gaps detected (reserved).
1086    pub coverage_gaps: u64,
1087    /// Pending-source inputs that attempted a canonical cache effect.
1088    pub pending_contamination: u64,
1089    /// Verdicts served past their freshness horizon (reserved).
1090    pub stale_verdicts: u64,
1091}
1092
1093/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
1094///
1095/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
1096/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
1097/// into a plain [`CacheMetricsSnapshot`].
1098#[derive(Debug, Default)]
1099struct CacheMetrics {
1100    deep_reorgs: AtomicU64,
1101    reorgs_recovered: AtomicU64,
1102    resync_requests: AtomicU64,
1103    resync_failures: AtomicU64,
1104    missed_ranges: AtomicU64,
1105    coverage_gaps: AtomicU64,
1106    pending_contamination: AtomicU64,
1107    stale_verdicts: AtomicU64,
1108}
1109
1110impl CacheMetrics {
1111    fn snapshot(&self) -> CacheMetricsSnapshot {
1112        CacheMetricsSnapshot {
1113            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
1114            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
1115            resync_requests: self.resync_requests.load(Ordering::Relaxed),
1116            resync_failures: self.resync_failures.load(Ordering::Relaxed),
1117            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
1118            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
1119            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
1120            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
1121        }
1122    }
1123}
1124
1125/// Runtime report.
1126#[derive(Clone, Debug)]
1127#[non_exhaustive]
1128pub enum ReactiveReport<N: Network = Ethereum> {
1129    /// Input was accepted after deduplication.
1130    Input(InputReport<N>),
1131    /// Handlers produced outcomes.
1132    Decoded(DecodedReport<N>),
1133    /// Direct state effects were applied.
1134    Applied(AppliedReport<N>),
1135    /// Resync request was scheduled or completed.
1136    Resynced(ResyncReport),
1137    /// Block-level processing completed.
1138    BlockCommitted(BlockReport<N>),
1139    /// Reorg processing report.
1140    Reorg(ReorgReport<N>),
1141    /// A forward gap in the canonical block sequence was detected: blocks between
1142    /// the last-seen head and an arriving block were never observed.
1143    MissedBlockRange(MissedRangeReport<N>),
1144    /// Cache health transitioned between states.
1145    Health(HealthReport<N>),
1146    /// A tracked account's storage root moved with no covering decoder — a
1147    /// coverage gap the per-block root gate detected (Phase-8 step 4).
1148    CoverageGap(CoverageGapReport<N>),
1149    /// Runtime or handler error.
1150    Error(ReactiveErrorReport<N>),
1151}
1152
1153/// Input acceptance report.
1154#[derive(Clone, Debug)]
1155pub struct InputReport<N: Network = Ethereum> {
1156    /// Input reference.
1157    pub input_ref: InputRef,
1158    /// Input context.
1159    pub context: ReactiveContext,
1160    /// Network marker.
1161    pub _network: PhantomData<N>,
1162}
1163
1164/// Decoding report.
1165#[derive(Clone, Debug)]
1166pub struct DecodedReport<N: Network = Ethereum> {
1167    /// Input reference.
1168    pub input_ref: InputRef,
1169    /// Handler ids that matched the input.
1170    pub handler_ids: Vec<HandlerId>,
1171    /// Network marker.
1172    pub _network: PhantomData<N>,
1173}
1174
1175/// Applied state report.
1176#[derive(Clone, Debug)]
1177pub struct AppliedReport<N: Network = Ethereum> {
1178    /// Input reference.
1179    pub input_ref: InputRef,
1180    /// Handler that produced the applied effects.
1181    pub handler_id: HandlerId,
1182    /// State effect quality.
1183    pub quality: StateEffectQuality,
1184    /// Labels emitted by the handler.
1185    pub tags: Vec<ReportTag>,
1186    /// Merged state diff from applied updates and invalidations.
1187    pub diff: StateDiff,
1188    /// State updates applied through the cache.
1189    pub state_updates: Vec<StateUpdate>,
1190    /// Invalidation requests lowered to purge updates.
1191    pub invalidations: Vec<InvalidationRequest>,
1192    /// Resync requests surfaced for a scheduler.
1193    pub resyncs: Vec<ResyncRequest>,
1194    /// Speculative requests surfaced for downstream users.
1195    pub speculative: Vec<SpeculativeRequest>,
1196    /// Hook signals emitted by the handler.
1197    pub hook_signals: Vec<HookSignal>,
1198    /// Network marker.
1199    pub _network: PhantomData<N>,
1200}
1201
1202/// Report of the storage resync requests executed during an ingest cycle: the
1203/// requests considered, the authoritative updates built from successful fetches
1204/// (and their applied diff), and any targets that could not be resynced.
1205#[derive(Clone, Debug, Default, PartialEq, Eq)]
1206pub struct ResyncReport {
1207    /// Requests considered by the resync execution pass.
1208    pub requested: Vec<ResyncRequest>,
1209    /// Authoritative state updates built from successful resync fetches.
1210    pub state_updates: Vec<StateUpdate>,
1211    /// Diff returned by applying [`state_updates`](Self::state_updates).
1212    pub diff: StateDiff,
1213    /// Targets that could not be resynced.
1214    pub failed: Vec<ResyncFailure>,
1215}
1216
1217/// One resync target that could not be fetched or applied.
1218#[derive(Clone, Debug, PartialEq, Eq)]
1219pub struct ResyncFailure {
1220    /// Request that produced the failed target.
1221    pub request_id: ResyncId,
1222    /// Block selection used for the failed target.
1223    pub block: ResyncBlock,
1224    /// Target that could not be resynced.
1225    pub target: ResyncTarget,
1226    /// Stable failure classification for retry policy and metrics.
1227    pub kind: ResyncFailureKind,
1228    /// Human-readable failure reason.
1229    pub message: String,
1230}
1231
1232/// Stable classification for a failed resync target.
1233#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1234#[non_exhaustive]
1235pub enum ResyncFailureKind {
1236    /// A storage target could not be fetched because no storage batch fetcher is configured.
1237    MissingStorageFetcher,
1238    /// The storage batch fetcher returned an error for the requested slot.
1239    StorageFetchFailed,
1240    /// The storage batch fetcher did not return a result for the requested slot.
1241    StorageFetchOmitted,
1242    /// An account target could not be fetched because no account proof fetcher is configured.
1243    MissingAccountFetcher,
1244    /// The account proof fetcher returned an error for the requested address.
1245    AccountFetchFailed,
1246    /// The account proof fetcher did not return a result for the requested address.
1247    AccountFetchOmitted,
1248}
1249
1250/// Block processing report.
1251#[derive(Clone, Debug)]
1252pub struct BlockReport<N: Network = Ethereum> {
1253    /// Block reference, when known.
1254    pub block: Option<BlockRef>,
1255    /// Input references committed for the block.
1256    pub inputs: Vec<InputRef>,
1257    /// Network marker.
1258    pub _network: PhantomData<N>,
1259}
1260
1261/// Report of a detected reorg and the recovery it performed: the dropped
1262/// block(s) and inputs, the exact rollback updates applied for reversible dropped
1263/// effects, the conservative purge updates for irreversible ones, the canceled
1264/// hash-pinned resyncs, and why recovery ran.
1265///
1266/// Recovery only covers blocks still resident in the journal. If a reorg runs
1267/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
1268/// appear here and their effects are neither rolled back nor purged (the runtime
1269/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
1270/// backstop for that span.
1271#[derive(Clone, Debug)]
1272pub struct ReorgReport<N: Network = Ethereum> {
1273    /// First dropped block, when known.
1274    pub dropped: Option<BlockRef>,
1275    /// Blocks dropped from the journal, in ascending journal order.
1276    pub dropped_blocks: Vec<BlockRef>,
1277    /// Input references that belonged to dropped blocks.
1278    pub dropped_inputs: Vec<InputRef>,
1279    /// Exact rollback updates applied for reversible dropped effects.
1280    pub rollback_updates: Vec<StateUpdate>,
1281    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
1282    pub rollback_diff: StateDiff,
1283    /// Conservative purge updates applied for irreversible dropped effects.
1284    pub purge_updates: Vec<StateUpdate>,
1285    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
1286    pub purge_diff: StateDiff,
1287    /// Hash-pinned pending resync requests canceled because their block was dropped.
1288    pub canceled_resyncs: Vec<ResyncRequest>,
1289    /// Reorg trigger.
1290    pub reason: ReorgReason,
1291    /// Network marker.
1292    pub _network: PhantomData<N>,
1293}
1294
1295/// Reason reorg recovery ran.
1296#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1297pub enum ReorgReason {
1298    /// A provider emitted an Alloy removed log.
1299    RemovedLog,
1300    /// The input context explicitly marked an input as reorged.
1301    ReorgedInput,
1302    /// A canonical block did not connect to the journaled head.
1303    ParentMismatch,
1304}
1305
1306/// Report of a forward gap in the canonical block sequence: an arriving block
1307/// whose number is more than one past the last-seen head, so the blocks in
1308/// between were never observed (for example during a subscription disconnect).
1309///
1310/// The arriving block is still accepted and applied — the chain extends — so this
1311/// report only makes the skipped span observable; it does not drop the block. The
1312/// span `from..=to` is inclusive of both endpoints.
1313#[derive(Clone, Debug)]
1314pub struct MissedRangeReport<N: Network = Ethereum> {
1315    /// First skipped block (`last-seen block number + 1`).
1316    pub from: u64,
1317    /// Last skipped block (`arriving block number - 1`).
1318    pub to: u64,
1319    /// The arriving block's number.
1320    pub block: u64,
1321    /// Network marker.
1322    pub _network: PhantomData<N>,
1323}
1324
1325/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
1326/// caused it and delivered to hooks through the normal dispatch path.
1327#[derive(Clone, Debug)]
1328pub struct HealthReport<N: Network = Ethereum> {
1329    /// Health state before the transition.
1330    pub from: CacheHealth,
1331    /// Health state after the transition.
1332    pub to: CacheHealth,
1333    /// Block number associated with the transition, when known.
1334    pub block: Option<u64>,
1335    /// Network marker.
1336    pub _network: PhantomData<N>,
1337}
1338
1339/// Report that a tracked account's storage root moved on a canonical block that
1340/// no decoder covered — a coverage gap surfaced by the per-block root gate
1341/// (Phase-8 step 4).
1342///
1343/// An account's `storageHash` is a collision-resistant commitment over all of its
1344/// storage, so a moved root proves *something* under the account changed. When
1345/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
1346/// batch's touched-address set does not include it, the change arrived through a
1347/// path no decoder observed. The runtime emits this report (delivered through the
1348/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
1349/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
1350/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
1351#[derive(Clone, Debug)]
1352pub struct CoverageGapReport<N: Network = Ethereum> {
1353    /// The tracked account whose root moved with no covering decoder.
1354    pub address: Address,
1355    /// The canonical block number at which the gap was observed.
1356    pub block: u64,
1357    /// Network marker.
1358    pub _network: PhantomData<N>,
1359}
1360
1361/// Report of a non-fatal error surfaced during an ingest cycle, with the
1362/// associated input (when known) and a human-readable message.
1363#[derive(Clone, Debug)]
1364pub struct ReactiveErrorReport<N: Network = Ethereum> {
1365    /// Input associated with the error, when known.
1366    pub input_ref: Option<InputRef>,
1367    /// Error message.
1368    pub message: String,
1369    /// Network marker.
1370    pub _network: PhantomData<N>,
1371}
1372
1373/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
1374/// [`ReactiveRuntime::ingest_batch_with_resync`].
1375#[derive(Clone, Debug)]
1376pub struct ReactiveBatchReport<N: Network = Ethereum> {
1377    /// Applied reports in commit order.
1378    pub applied: Vec<AppliedReport<N>>,
1379    /// Resync requests surfaced during the batch.
1380    pub resyncs: Vec<ResyncRequest>,
1381    /// Speculative requests surfaced during the batch.
1382    pub speculative: Vec<SpeculativeRequest>,
1383    /// Hook reports dispatched after mutation phases.
1384    pub reports: Vec<Arc<ReactiveReport<N>>>,
1385}
1386
1387impl<N: Network> Default for ReactiveBatchReport<N> {
1388    fn default() -> Self {
1389        Self {
1390            applied: Vec::new(),
1391            resyncs: Vec::new(),
1392            speculative: Vec::new(),
1393            reports: Vec::new(),
1394        }
1395    }
1396}
1397
1398/// Error returned by a handler.
1399#[derive(Clone, Debug, PartialEq, Eq)]
1400pub struct HandlerError {
1401    message: String,
1402}
1403
1404impl HandlerError {
1405    /// Create a handler error from a message.
1406    pub fn new(message: impl Into<String>) -> Self {
1407        Self {
1408            message: message.into(),
1409        }
1410    }
1411}
1412
1413impl fmt::Display for HandlerError {
1414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1415        self.message.fmt(f)
1416    }
1417}
1418
1419impl std::error::Error for HandlerError {}
1420
1421impl From<String> for HandlerError {
1422    fn from(message: String) -> Self {
1423        Self::new(message)
1424    }
1425}
1426
1427impl From<&str> for HandlerError {
1428    fn from(message: &str) -> Self {
1429        Self::new(message)
1430    }
1431}
1432
1433/// Runtime error.
1434#[derive(Debug, thiserror::Error)]
1435pub enum ReactiveError {
1436    /// Handler returned an error.
1437    #[error("handler `{handler_id}` failed: {source}")]
1438    HandlerFailed {
1439        /// Handler id.
1440        handler_id: HandlerId,
1441        /// Handler error.
1442        source: HandlerError,
1443    },
1444    /// Multiple handlers emitted incompatible absolute writes for one input.
1445    #[error(
1446        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
1447    )]
1448    ConflictingEffects {
1449        /// Input reference.
1450        input_ref: Box<InputRef>,
1451        /// Conflicting target.
1452        target: Box<EffectTarget>,
1453        /// First handler id.
1454        first: HandlerId,
1455        /// Second handler id.
1456        second: HandlerId,
1457    },
1458    /// Pending inputs attempted to mutate canonical cache state.
1459    #[error(
1460        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
1461    )]
1462    InvalidPendingEffect {
1463        /// Input reference.
1464        input_ref: Box<InputRef>,
1465        /// Handler id.
1466        handler_id: HandlerId,
1467        /// Effect kind.
1468        effect_kind: &'static str,
1469    },
1470    /// Registration error.
1471    #[error(transparent)]
1472    Register(#[from] RegisterError),
1473}
1474
1475/// Handler registration error.
1476#[derive(Debug, thiserror::Error)]
1477pub enum RegisterError {
1478    /// Duplicate handler id.
1479    #[error("handler id `{0}` is already registered")]
1480    DuplicateHandler(HandlerId),
1481}
1482
1483/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
1484/// runtime and subscriber sides.
1485#[derive(Debug, thiserror::Error)]
1486pub enum ReactiveEngineRegisterError {
1487    /// Runtime registry rejected the handler.
1488    #[error(transparent)]
1489    Register(#[from] RegisterError),
1490    /// Subscriber rejected the handler's interests.
1491    #[error(transparent)]
1492    Subscriber(#[from] SubscriberError),
1493}
1494
1495/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
1496/// and runtime ingestion.
1497#[derive(Debug, thiserror::Error)]
1498pub enum ReactiveEngineError {
1499    /// Subscriber polling failed.
1500    #[error(transparent)]
1501    Subscriber(#[from] SubscriberError),
1502    /// Runtime ingestion failed.
1503    #[error(transparent)]
1504    Runtime(#[from] ReactiveError),
1505}
1506
1507/// Absolute write target used for conflict reports.
1508#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1509pub enum EffectTarget {
1510    /// Storage slot target.
1511    StorageSlot {
1512        /// Contract address.
1513        address: Address,
1514        /// Storage slot.
1515        slot: U256,
1516    },
1517    /// Account balance target.
1518    AccountBalance {
1519        /// Account address.
1520        address: Address,
1521    },
1522    /// Account nonce target.
1523    AccountNonce {
1524        /// Account address.
1525        address: Address,
1526    },
1527    /// Account code target.
1528    AccountCode {
1529        /// Account address.
1530        address: Address,
1531    },
1532    /// Masked storage slot target.
1533    MaskedStorageSlot {
1534        /// Contract address.
1535        address: Address,
1536        /// Storage slot.
1537        slot: U256,
1538        /// Bit mask.
1539        mask: U256,
1540    },
1541}
1542
1543#[derive(Clone, Debug, PartialEq, Eq)]
1544enum AbsoluteValue {
1545    U256(U256),
1546    U64(u64),
1547    Bytes(Bytes),
1548}
1549
1550/// Reactive runtime.
1551pub struct ReactiveRuntime<N: Network = Ethereum> {
1552    registry: ReactiveRegistry<N>,
1553    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
1554    config: ReactiveConfig,
1555    journal: VecDeque<BlockJournal<N>>,
1556    pending_resyncs: Vec<ResyncRequest>,
1557    health: CacheHealth,
1558    metrics: CacheMetrics,
1559    /// Opt-in freshness registry the runtime stamps for canonical event writes.
1560    ///
1561    /// `None` by default (behavior unchanged); populated by
1562    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
1563    /// present, applying a canonical handler storage-slot effect stamps the
1564    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
1565    /// so event-maintained slots stop being needlessly re-verified while aging to
1566    /// volatile once the clock passes `N`.
1567    freshness: Option<FreshnessRegistry>,
1568    /// Per-account tracking registry consulted by the per-block root gate
1569    /// (Phase-8 step 4). Empty by default; populated by
1570    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
1571    tracking: HashMap<Address, TrackingPolicy>,
1572    /// Per-account root/field baselines the gate diffs against across blocks.
1573    /// Adopted on first probe and re-adopted on every observed move.
1574    tracked_roots: HashMap<Address, TrackedRoot>,
1575    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
1576    root_gate_cadence: RootGateCadence,
1577    /// Canonical block of the last root-gate firing. `None` until the first
1578    /// firing (which happens at the first canonical block ever seen, so
1579    /// baseline adoption never waits a full cadence window).
1580    last_gate_block: Option<u64>,
1581    /// Union of decoder-touched addresses since the last root-gate firing,
1582    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
1583    /// touched" must judge against every covered write in the window, or a
1584    /// decoder-covered write in a skipped block would false-positive as a
1585    /// [`ReactiveReport::CoverageGap`].
1586    touched_since_gate: HashSet<Address>,
1587}
1588
1589#[derive(Clone, Debug)]
1590struct BlockJournal<N: Network = Ethereum> {
1591    block: BlockRef,
1592    inputs: Vec<InputRef>,
1593    applied: Vec<AppliedReport<N>>,
1594    resynced: Vec<ResyncReport>,
1595}
1596
1597/// Registry and router for provider-neutral reactive handlers.
1598///
1599/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
1600/// consolidated provider-side log filters for subscription setup, and routes
1601/// provider logs back to the exact matching log interests. Consolidated filters
1602/// may be safe supersets; [`Self::route_log`] always re-checks the original
1603/// [`LogInterest`] and its local matcher before returning a route.
1604pub struct ReactiveRegistry<N: Network = Ethereum> {
1605    handlers: BTreeMap<u128, RegisteredHandler<N>>,
1606    handler_positions: HashMap<HandlerId, u128>,
1607    next_handler_position: u128,
1608    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
1609    fallback_log_handlers: BTreeSet<u128>,
1610    data_slice_shapes: HashMap<(usize, usize), usize>,
1611}
1612
1613struct RegisteredHandler<N: Network = Ethereum> {
1614    id: HandlerId,
1615    handler: Arc<dyn ReactiveHandler<N>>,
1616    interests: Vec<ReactiveInterest<N>>,
1617    has_log_interests: bool,
1618    log_route_index: Option<LogRouteIndex>,
1619}
1620
1621impl<N: Network> Default for ReactiveRegistry<N> {
1622    fn default() -> Self {
1623        Self::new()
1624    }
1625}
1626
1627impl<N: Network> ReactiveRegistry<N> {
1628    /// Create an empty registry.
1629    pub fn new() -> Self {
1630        Self {
1631            handlers: BTreeMap::new(),
1632            handler_positions: HashMap::new(),
1633            next_handler_position: 0,
1634            indexed_log_handlers: HashMap::new(),
1635            fallback_log_handlers: BTreeSet::new(),
1636            data_slice_shapes: HashMap::new(),
1637        }
1638    }
1639
1640    /// Register a handler, preserving registration order.
1641    ///
1642    /// Duplicate handler ids are rejected with
1643    /// [`RegisterError::DuplicateHandler`].
1644    pub fn register_handler(
1645        &mut self,
1646        handler: Arc<dyn ReactiveHandler<N>>,
1647    ) -> Result<(), RegisterError> {
1648        let id = handler.id();
1649        if self.handler_positions.contains_key(&id) {
1650            return Err(RegisterError::DuplicateHandler(id));
1651        }
1652        let interests = handler.interests();
1653        let has_log_interests = interests
1654            .iter()
1655            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
1656        let log_route_index = handler.log_route_index();
1657        if self.next_handler_position == u128::MAX {
1658            self.compact_handler_positions();
1659        }
1660        let position = self.next_handler_position;
1661        self.next_handler_position += 1;
1662        self.handler_positions.insert(id.clone(), position);
1663        if let Some(index) = &log_route_index {
1664            for key in index.keys() {
1665                if let LogRouteKey::DataSlice { offset, value } = key {
1666                    *self
1667                        .data_slice_shapes
1668                        .entry((*offset, value.len()))
1669                        .or_default() += 1;
1670                }
1671                self.indexed_log_handlers
1672                    .entry(key.clone())
1673                    .or_default()
1674                    .insert(position);
1675            }
1676        } else if has_log_interests {
1677            self.fallback_log_handlers.insert(position);
1678        }
1679        self.handlers.insert(
1680            position,
1681            RegisteredHandler {
1682                id,
1683                handler,
1684                interests,
1685                has_log_interests,
1686                log_route_index,
1687            },
1688        );
1689        Ok(())
1690    }
1691
1692    /// Remove one handler by id, leaving all other handlers and interests intact.
1693    ///
1694    /// Returns the removed handler when the id was registered. Cache eviction is
1695    /// intentionally outside this API: unregistering stops future routing and
1696    /// decode for the handler only.
1697    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
1698        let position = self.handler_positions.remove(id)?;
1699        let registered = self.handlers.remove(&position)?;
1700        if let Some(index) = &registered.log_route_index {
1701            for key in index.keys() {
1702                let remove_bucket = self
1703                    .indexed_log_handlers
1704                    .get_mut(key)
1705                    .is_some_and(|owners| {
1706                        owners.remove(&position);
1707                        owners.is_empty()
1708                    });
1709                if remove_bucket {
1710                    self.indexed_log_handlers.remove(key);
1711                }
1712                if let LogRouteKey::DataSlice { offset, value } = key {
1713                    let shape = (*offset, value.len());
1714                    let remove_shape =
1715                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
1716                            *count -= 1;
1717                            *count == 0
1718                        });
1719                    if remove_shape {
1720                        self.data_slice_shapes.remove(&shape);
1721                    }
1722                }
1723            }
1724        } else {
1725            self.fallback_log_handlers.remove(&position);
1726        }
1727        Some(registered.handler)
1728    }
1729
1730    /// Return true when `id` is currently registered.
1731    pub fn contains_handler(&self, id: &HandlerId) -> bool {
1732        self.handler_positions.contains_key(id)
1733    }
1734
1735    /// Ids of all registered handlers, in registration (= routing) order.
1736    pub fn handler_ids(&self) -> Vec<HandlerId> {
1737        self.handlers
1738            .values()
1739            .map(|handler| handler.id.clone())
1740            .collect()
1741    }
1742
1743    /// Borrow the interests owned by one handler.
1744    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
1745        self.handler_positions
1746            .get(id)
1747            .and_then(|position| self.handlers.get(position))
1748            .map(|registered| registered.interests.as_slice())
1749    }
1750
1751    /// Return all registered interests in handler registration order.
1752    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
1753        self.handlers
1754            .values()
1755            .flat_map(|handler| handler.interests.clone())
1756            .collect()
1757    }
1758
1759    /// Return consolidated provider-side log filters.
1760    ///
1761    /// Filters are emitted in deterministic first-registration order by
1762    /// compatible block option. Within each returned filter, address and topic
1763    /// sets are unioned independently, which can intentionally overfetch. Use
1764    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
1765    pub fn log_subscription_filters(&self) -> Vec<Filter> {
1766        let mut filters = Vec::new();
1767        for interest in self.log_interests() {
1768            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
1769        }
1770        filters
1771    }
1772
1773    /// Route a log to exact matching handler interests.
1774    ///
1775    /// Routes are returned in handler registration order. Each handler appears
1776    /// at most once for a log, using the first matching log interest declared by
1777    /// that handler.
1778    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
1779        self.log_handler_candidates(log)
1780            .into_iter()
1781            .filter_map(|handler| handler.route_log(log))
1782            .collect()
1783    }
1784
1785    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
1786        let mut indexed_positions = Vec::new();
1787        if let Some(indexed) = self
1788            .indexed_log_handlers
1789            .get(&LogRouteKey::Emitter(log.address()))
1790        {
1791            indexed_positions.extend(indexed.iter().copied());
1792        }
1793        for (index, value) in log.topics().iter().copied().enumerate() {
1794            if let Some(indexed) = self
1795                .indexed_log_handlers
1796                .get(&LogRouteKey::Topic { index, value })
1797            {
1798                indexed_positions.extend(indexed.iter().copied());
1799            }
1800        }
1801        let data = log.inner.data.data.as_ref();
1802        for &(offset, len) in self.data_slice_shapes.keys() {
1803            let Some(end) = offset.checked_add(len) else {
1804                continue;
1805            };
1806            let Some(value) = data.get(offset..end) else {
1807                continue;
1808            };
1809            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
1810                offset,
1811                value: value.to_vec(),
1812            }) {
1813                indexed_positions.extend(indexed.iter().copied());
1814            }
1815        }
1816        if indexed_positions.is_empty() {
1817            if self.fallback_log_handlers.is_empty() {
1818                return Vec::new();
1819            }
1820            if !self.indexed_log_handlers.is_empty() {
1821                return self
1822                    .fallback_log_handlers
1823                    .iter()
1824                    .filter_map(|position| self.handlers.get(position))
1825                    .collect();
1826            }
1827            return self
1828                .handlers
1829                .values()
1830                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
1831                .collect();
1832        }
1833
1834        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
1835        indexed_positions.sort_unstable();
1836        indexed_positions.dedup();
1837        indexed_positions
1838            .into_iter()
1839            .filter_map(|position| self.handlers.get(&position))
1840            .collect()
1841    }
1842
1843    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
1844        self.handlers.values()
1845    }
1846
1847    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
1848        self.handlers.values().flat_map(|handler| {
1849            handler
1850                .interests
1851                .iter()
1852                .filter_map(|interest| match interest {
1853                    ReactiveInterest::Logs(interest) => Some(interest),
1854                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
1855                })
1856        })
1857    }
1858
1859    fn compact_handler_positions(&mut self) {
1860        let handlers = std::mem::take(&mut self.handlers);
1861        self.handler_positions.clear();
1862        self.indexed_log_handlers.clear();
1863        self.fallback_log_handlers.clear();
1864        self.data_slice_shapes.clear();
1865
1866        for (position, (_, handler)) in handlers.into_iter().enumerate() {
1867            let position = position as u128;
1868            self.handler_positions.insert(handler.id.clone(), position);
1869            if let Some(index) = &handler.log_route_index {
1870                for key in index.keys() {
1871                    if let LogRouteKey::DataSlice { offset, value } = key {
1872                        *self
1873                            .data_slice_shapes
1874                            .entry((*offset, value.len()))
1875                            .or_default() += 1;
1876                    }
1877                    self.indexed_log_handlers
1878                        .entry(key.clone())
1879                        .or_default()
1880                        .insert(position);
1881                }
1882            } else if handler.has_log_interests {
1883                self.fallback_log_handlers.insert(position);
1884            }
1885            self.handlers.insert(position, handler);
1886        }
1887        self.next_handler_position = self.handlers.len() as u128;
1888    }
1889}
1890
1891impl<N: Network> ReactiveRuntime<N> {
1892    /// Create an empty runtime.
1893    pub fn new(config: ReactiveConfig) -> Self {
1894        Self {
1895            registry: ReactiveRegistry::new(),
1896            hooks: Vec::new(),
1897            config,
1898            journal: VecDeque::new(),
1899            pending_resyncs: Vec::new(),
1900            health: CacheHealth::Healthy,
1901            metrics: CacheMetrics::default(),
1902            freshness: None,
1903            tracking: HashMap::new(),
1904            tracked_roots: HashMap::new(),
1905            root_gate_cadence: RootGateCadence::default(),
1906            last_gate_block: None,
1907            touched_since_gate: HashSet::new(),
1908        }
1909    }
1910
1911    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
1912    ///
1913    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
1914    /// gate as a no-op. Registering an account clears any baseline it held (a
1915    /// policy change re-adopts on the next probe rather than diffing against a
1916    /// baseline captured under the old policy). Each [`RootGateCadence`]
1917    /// firing, the gate
1918    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
1919    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
1920    /// account-proof seam and, on a move no decoder covered, emits a
1921    /// [`ReactiveReport::CoverageGap`] and schedules a
1922    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
1923    /// accounts are never root-gated (spec Decision 3).
1924    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
1925        self.tracking.insert(address, policy);
1926        self.tracked_roots.remove(&address);
1927    }
1928
1929    /// Stop tracking `address`, dropping its policy and any adopted baseline.
1930    ///
1931    /// Returns `true` if the account was tracked.
1932    pub fn untrack_account(&mut self, address: Address) -> bool {
1933        self.tracked_roots.remove(&address);
1934        self.tracking.remove(&address).is_some()
1935    }
1936
1937    /// Set how often the root gate probes tracked accounts (default:
1938    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
1939    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
1940    ///
1941    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
1942    /// accumulator and the last-fired block), so a stale window never leaks
1943    /// into the new cadence: the next canonical block fires the gate.
1944    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
1945        self.root_gate_cadence = cadence;
1946        self.last_gate_block = None;
1947        self.touched_since_gate.clear();
1948    }
1949
1950    /// The configured [`RootGateCadence`].
1951    pub fn root_gate_cadence(&self) -> RootGateCadence {
1952        self.root_gate_cadence
1953    }
1954
1955    /// Enable freshness stamping of canonical event-derived writes (opt-in).
1956    ///
1957    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
1958    /// applying a canonical handler storage-slot effect for a block `N` stamps the
1959    /// touched `(address, slot)` as
1960    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
1961    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
1962    /// re-verify) but ages to volatile once the clock passes `N`.
1963    ///
1964    /// Idempotent: if a registry is already installed it is left untouched, so an
1965    /// existing registry (and any stamps it holds) is never clobbered.
1966    pub fn enable_freshness_stamping(&mut self) {
1967        if self.freshness.is_none() {
1968            self.freshness = Some(FreshnessRegistry::new());
1969        }
1970    }
1971
1972    /// Borrow the runtime's freshness registry, if stamping was enabled.
1973    ///
1974    /// Returns `None` unless
1975    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
1976    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
1977        self.freshness.as_ref()
1978    }
1979
1980    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
1981    ///
1982    /// Returns `None` unless
1983    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
1984    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
1985        self.freshness.as_mut()
1986    }
1987
1988    /// Return the current queryable [`CacheHealth`] of the runtime.
1989    pub fn health(&self) -> CacheHealth {
1990        self.health
1991    }
1992
1993    /// Return a point-in-time snapshot of the runtime's observability counters.
1994    pub fn metrics(&self) -> CacheMetricsSnapshot {
1995        self.metrics.snapshot()
1996    }
1997
1998    /// Complete the caller-driven self-heal by returning health to
1999    /// [`CacheHealth::Healthy`].
2000    ///
2001    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
2002    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
2003    /// "stop until rebuilt" signal that the caller must act on. Once the caller
2004    /// has resynced or rebuilt the affected state, it invokes this to clear the
2005    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
2006    /// called outside an ingest cycle.
2007    pub fn reset_health(&mut self) {
2008        self.health = CacheHealth::Healthy;
2009    }
2010
2011    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
2012    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
2013    /// state actually changes.
2014    ///
2015    /// The ladder is:
2016    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
2017    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
2018    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
2019    ///
2020    /// A first event degrades; a second escalates to the terminal
2021    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
2022    /// trust-loss paths (deep reorg beyond the journal and missed-range
2023    /// detection) so mixed event types climb the same ladder.
2024    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
2025        let to = match self.health {
2026            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
2027            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
2028            CacheHealth::Unhealthy { .. } => return None,
2029        };
2030        self.transition_health(to, Some(block))
2031    }
2032
2033    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
2034    /// when the state actually changes.
2035    ///
2036    /// The returned report must be threaded into the ingest cycle's dispatched
2037    /// reports so it reaches hooks and appears in
2038    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
2039    /// current state (no transition, no report).
2040    fn transition_health(
2041        &mut self,
2042        to: CacheHealth,
2043        block: Option<u64>,
2044    ) -> Option<Arc<ReactiveReport<N>>> {
2045        if to == self.health {
2046            return None;
2047        }
2048        let from = self.health;
2049        self.health = to;
2050        Some(Arc::new(ReactiveReport::Health(HealthReport {
2051            from,
2052            to,
2053            block,
2054            _network: PhantomData,
2055        })))
2056    }
2057
2058    /// Register a handler.
2059    pub fn register_handler(
2060        &mut self,
2061        handler: Arc<dyn ReactiveHandler<N>>,
2062    ) -> Result<(), RegisterError> {
2063        self.registry.register_handler(handler)
2064    }
2065
2066    /// Remove one handler from the runtime registry without resetting runtime state.
2067    ///
2068    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
2069    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
2070    /// tracking policy, freshness registry, or root-gate baselines, and it does
2071    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
2072    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
2073    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
2074        self.registry.unregister_handler(id)
2075    }
2076
2077    /// Return true when the runtime has a registered handler with `id`.
2078    pub fn contains_handler(&self, id: &HandlerId) -> bool {
2079        self.registry.contains_handler(id)
2080    }
2081
2082    /// Ids of all registered handlers, in registration (= routing) order.
2083    pub fn handler_ids(&self) -> Vec<HandlerId> {
2084        self.registry.handler_ids()
2085    }
2086
2087    /// Borrow the interests owned by one registered handler.
2088    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
2089        self.registry.handler_interests(id)
2090    }
2091
2092    /// The most recently journaled canonical block, if any.
2093    ///
2094    /// This is the runtime's current chain position: the canonical block most
2095    /// recently recorded by ingestion. Reorged blocks are dropped from the
2096    /// journal during recovery, so a rolled-back head does not linger here.
2097    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
2098    /// anchor for handlers registered mid-lifecycle. `None` until the first
2099    /// canonical input is journaled, and always `None` when
2100    /// [`ReactiveConfig::journal_depth`] is 0 (journaling disabled).
2101    pub fn last_canonical_block(&self) -> Option<BlockRef> {
2102        self.journal.back().map(|entry| entry.block.clone())
2103    }
2104
2105    /// Return whether the retained reorg journal still contains an applied
2106    /// record for `handler_id`.
2107    ///
2108    /// The record is retained even when the handler emitted only resync work,
2109    /// so an owner can keep an explicit cache-eviction fence active for exactly
2110    /// as long as a later rollback could restore effects from that handler
2111    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
2112    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
2113        self.journal.iter().any(|entry| {
2114            entry
2115                .applied
2116                .iter()
2117                .any(|applied| &applied.handler_id == handler_id)
2118        })
2119    }
2120
2121    /// Return the distinct handler generations represented in the retained
2122    /// reorg journal.
2123    ///
2124    /// This scans the bounded journal once, allowing a lifecycle owner to age a
2125    /// large set of cache-eviction fences without rescanning the journal for
2126    /// every handler.
2127    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
2128        self.journal
2129            .iter()
2130            .flat_map(|entry| entry.applied.iter())
2131            .map(|applied| applied.handler_id.clone())
2132            .collect()
2133    }
2134
2135    /// Queued resync requests: surfaced by handlers but not yet executed by an
2136    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
2137    ///
2138    /// Callers driving resync execution themselves (plain
2139    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
2140    /// reorg recovery cancels entries whose pinned blocks were dropped, and
2141    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
2142    /// generation-owned work, while
2143    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
2144    /// for exclusively torn-down accounts.
2145    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
2146        &self.pending_resyncs
2147    }
2148
2149    /// Cancel every queued request with the exact logical `id`.
2150    ///
2151    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
2152    /// removes whole requests and never touches other work merely because it
2153    /// targets the same account. It is therefore the safe primitive for
2154    /// generation-scoped owner teardown when the caller maintains an
2155    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
2156    /// an earlier batch report cannot be recalled.
2157    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
2158        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
2159    }
2160
2161    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
2162    ///
2163    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
2164    /// pending-queue order, independent of caller id order. This is the batch
2165    /// teardown primitive for owners that can have many pending repairs; it
2166    /// avoids rescanning the complete pending queue once per owned id.
2167    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
2168        if ids.is_empty() {
2169            return Vec::new();
2170        }
2171        let ids: HashSet<&ResyncId> = ids.iter().collect();
2172        let mut cancelled = Vec::new();
2173        self.pending_resyncs.retain(|request| {
2174            if ids.contains(&request.id) {
2175                cancelled.push(request.clone());
2176                false
2177            } else {
2178                true
2179            }
2180        });
2181        cancelled
2182    }
2183
2184    /// Cancel queued resync work that targets `address`, returning the
2185    /// cancelled portions.
2186    ///
2187    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
2188    /// a request reduced to zero targets is dropped entirely, while
2189    /// mixed-target requests keep their other accounts queued. Each returned
2190    /// request mirrors the original id/reason/block/priority and carries only
2191    /// the targets that were cancelled.
2192    ///
2193    /// This is appropriate only when the caller owns the complete account. For
2194    /// a pool sharing a vault or emitter with other owners, cancel its exact
2195    /// request IDs through
2196    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
2197    /// recall requests already returned to the caller in earlier batch reports.
2198    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
2199        let mut cancelled = Vec::new();
2200        self.pending_resyncs.retain_mut(|request| {
2201            let (matching, remaining): (Vec<_>, Vec<_>) = request
2202                .targets
2203                .drain(..)
2204                .partition(|target| resync_target_address(target) == address);
2205            request.targets = remaining;
2206            if !matching.is_empty() {
2207                cancelled.push(ResyncRequest {
2208                    id: request.id.clone(),
2209                    reason: request.reason.clone(),
2210                    block: request.block.clone(),
2211                    targets: matching,
2212                    priority: request.priority,
2213                });
2214            }
2215            !request.targets.is_empty()
2216        });
2217        cancelled
2218    }
2219
2220    /// Register a hook.
2221    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
2222        self.hooks.push(hook);
2223        Ok(())
2224    }
2225
2226    /// Return all registered interests in handler registration order.
2227    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
2228        self.registry.interests()
2229    }
2230
2231    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
2232    pub fn ingest_batch(
2233        &mut self,
2234        cache: &mut EvmCache,
2235        batch: ReactiveInputBatch<N>,
2236    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
2237        let batch_report = self.ingest_batch_direct(cache, batch)?;
2238        self.dispatch_reports(&batch_report.reports);
2239        let _ = &self.config;
2240        Ok(batch_report)
2241    }
2242
2243    /// Ingest a batch, then execute surfaced storage resync requests.
2244    ///
2245    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
2246    /// direct handler effects, then runs a synchronous resync phase over the
2247    /// collected [`ResyncRequest`]s. Storage targets are fetched through
2248    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
2249    /// values are applied as [`StateUpdate::slot`] updates through
2250    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
2251    /// in [`ResyncReport::failed`]. It does not start subscribers, background
2252    /// workers, or network transport.
2253    pub fn ingest_batch_with_resync(
2254        &mut self,
2255        cache: &mut EvmCache,
2256        batch: ReactiveInputBatch<N>,
2257    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
2258        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
2259
2260        if !batch_report.resyncs.is_empty() {
2261            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
2262            // Count unique logical requests: several handlers may emit the same
2263            // ResyncId in one batch, and duplicates fan out per-origin in the
2264            // report but are one unit of resync work for the metric.
2265            let unique_requests = resync_report
2266                .requested
2267                .iter()
2268                .map(|request| &request.id)
2269                .collect::<HashSet<_>>()
2270                .len();
2271            self.metrics
2272                .resync_requests
2273                .fetch_add(unique_requests as u64, Ordering::Relaxed);
2274            self.metrics
2275                .resync_failures
2276                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
2277            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
2278            self.record_journal_resync(&resync_report);
2279            batch_report
2280                .reports
2281                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
2282        }
2283
2284        self.dispatch_reports(&batch_report.reports);
2285        let _ = &self.config;
2286        Ok(batch_report)
2287    }
2288
2289    fn ingest_batch_direct(
2290        &mut self,
2291        cache: &mut EvmCache,
2292        batch: ReactiveInputBatch<N>,
2293    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
2294        let records = sort_records(dedupe_records(batch.into_records()));
2295
2296        let mut batch_report = ReactiveBatchReport::default();
2297        let mut reports_to_dispatch = Vec::new();
2298        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
2299        // batch (union of applied `StateDiff` addresses) and the batch's canonical
2300        // block number, so the per-block root gate can run once after the record
2301        // loop with the full touched set.
2302        let mut touched_addrs: HashSet<Address> = HashSet::new();
2303        let mut canonical_batch_block: Option<u64> = None;
2304
2305        for record in records {
2306            let input_ref = record.input_ref();
2307            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
2308                input_ref,
2309                context: record.context.clone(),
2310                _network: PhantomData,
2311            })));
2312
2313            if let Some(reorg_report) =
2314                self.recover_for_canonical_input(cache, &record, &mut reports_to_dispatch)
2315            {
2316                self.metrics
2317                    .reorgs_recovered
2318                    .fetch_add(1, Ordering::Relaxed);
2319                remove_canceled_resyncs_from_batch(
2320                    &mut batch_report.resyncs,
2321                    &reorg_report.canceled_resyncs,
2322                );
2323                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
2324            }
2325
2326            if let Some(reorg_report) =
2327                self.recover_for_reorged_input(cache, &record, &mut reports_to_dispatch)
2328            {
2329                self.metrics
2330                    .reorgs_recovered
2331                    .fetch_add(1, Ordering::Relaxed);
2332                remove_canceled_resyncs_from_batch(
2333                    &mut batch_report.resyncs,
2334                    &reorg_report.canceled_resyncs,
2335                );
2336                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
2337                continue;
2338            }
2339
2340            if let Some(block) = canonical_record_block(&record) {
2341                // Phase-8 step 4: remember the batch's canonical block (the last
2342                // canonical record wins) so the root gate probes at that height.
2343                canonical_batch_block = Some(block.number);
2344                self.record_journal_input(block, input_ref);
2345            }
2346
2347            // Phase-8 step 2: drive a per-block env refresh from canonical
2348            // headers. Best-effort — a strict validation failure is surfaced as
2349            // a non-fatal error report and does not abort the batch.
2350            if let Some(Err(err)) = advance_block_for_canonical_record(cache, &record) {
2351                reports_to_dispatch.push(Arc::new(ReactiveReport::Error(ReactiveErrorReport {
2352                    input_ref: Some(input_ref),
2353                    message: err.to_string(),
2354                    _network: PhantomData,
2355                })));
2356            }
2357
2358            let executions = self.execute_handlers(cache, &record, input_ref)?;
2359            if executions.is_empty() {
2360                continue;
2361            }
2362
2363            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
2364                input_ref,
2365                handler_ids: executions
2366                    .iter()
2367                    .map(|execution| execution.handler_id.clone())
2368                    .collect(),
2369                _network: PhantomData,
2370            })));
2371
2372            detect_conflicts(input_ref, &executions)?;
2373
2374            // Phase-8 step 3: canonical block number for freshness stamping.
2375            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
2376            // can be used while `self.freshness_mut()` mutably borrows `self`
2377            // inside the execution loop. `None` for pending/removed/reorged
2378            // records — those never stamp canonical freshness.
2379            let canonical_block_number = canonical_record_block(&record).map(|block| block.number);
2380
2381            for execution in executions {
2382                let diff = if execution.state_updates.is_empty() {
2383                    StateDiff::default()
2384                } else {
2385                    cache.apply_updates(&execution.state_updates)
2386                };
2387
2388                batch_report
2389                    .resyncs
2390                    .extend(execution.resyncs.iter().cloned());
2391                self.pending_resyncs
2392                    .extend(execution.resyncs.iter().cloned());
2393                batch_report
2394                    .speculative
2395                    .extend(execution.speculative.iter().cloned());
2396
2397                let applied = AppliedReport {
2398                    input_ref,
2399                    handler_id: execution.handler_id,
2400                    quality: execution.quality,
2401                    tags: execution.tags,
2402                    diff,
2403                    state_updates: execution.state_updates,
2404                    invalidations: execution.invalidations,
2405                    resyncs: execution.resyncs,
2406                    speculative: execution.speculative,
2407                    hook_signals: execution.hook_signals,
2408                    _network: PhantomData,
2409                };
2410                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
2411                // from this canonical handler write as `ValidThrough(N)`, so an
2412                // event-maintained slot stops being re-verified until the clock
2413                // passes its write block. Read the changed slots straight off
2414                // `applied.diff` (which borrows the local, not `self`) and stamp
2415                // via `self.freshness`, done before `applied` is moved into the
2416                // journal/batch below. Only genuinely-changed slots appear here,
2417                // since a no-op re-write records no `SlotChange`.
2418                if let (Some(number), Some(registry)) =
2419                    (canonical_block_number, self.freshness.as_mut())
2420                {
2421                    for change in &applied.diff.slots {
2422                        registry.valid_through_slot(change.address, change.slot, number);
2423                    }
2424                }
2425
2426                // Phase-8 step 4: record every address this decoder actually wrote
2427                // (or attempted to write) so the root gate can tell a
2428                // decoder-covered root move from an uncovered coverage gap. Fold in
2429                // the full `StateDiff` address footprint — real changes
2430                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
2431                // a decoder that tried to write a cold slot still counts as
2432                // covering the account.
2433                collect_diff_addresses(&applied.diff, &mut touched_addrs);
2434
2435                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
2436                reports_to_dispatch.push(report);
2437                if let Some(block) = canonical_record_block(&record) {
2438                    self.record_journal_applied(block, applied.clone());
2439                }
2440                batch_report.applied.push(applied);
2441            }
2442        }
2443
2444        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
2445        // addresses (after all handler effects, so the set is complete), then
2446        // fire the root gate only on cadence boundaries. The gate diffs
2447        // against persisted baselines, so skipped blocks lose no detection —
2448        // but the touched set must be the union since the last firing, or a
2449        // decoder-covered write in a skipped block would false-positive as a
2450        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
2451        // callers see them and `ingest_batch_with_resync` executes them) and
2452        // coverage reports go into the dispatched reports.
2453        if self.root_gate_runnable(cache) {
2454            self.touched_since_gate
2455                .extend(touched_addrs.iter().copied());
2456            if self.root_gate_due(canonical_batch_block) {
2457                let accumulated = std::mem::take(&mut self.touched_since_gate);
2458                self.run_root_gate(
2459                    cache,
2460                    canonical_batch_block,
2461                    &accumulated,
2462                    &mut batch_report.resyncs,
2463                    &mut reports_to_dispatch,
2464                );
2465                self.last_gate_block = canonical_batch_block;
2466            }
2467        } else {
2468            // A gate that cannot run (disabled, nothing root-gated, or no
2469            // proof fetcher) must not grow the accumulator unboundedly.
2470            // Dropping it is safe: without a runnable gate no baselines exist
2471            // (a fetcher cannot be uninstalled, and untracking drops the
2472            // baseline), so there is nothing a lost touched set could falsely
2473            // gap against later.
2474            self.touched_since_gate.clear();
2475        }
2476
2477        batch_report.reports = reports_to_dispatch;
2478        Ok(batch_report)
2479    }
2480
2481    /// Whether the root gate could produce any signal at all: some tracked
2482    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
2483    /// When this is false the touched accumulator is dropped rather than
2484    /// grown (see the ingest call site for why that is safe).
2485    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
2486        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
2487            return false;
2488        }
2489        let has_gated_targets = self
2490            .tracking
2491            .values()
2492            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
2493        has_gated_targets && cache.account_proof_fetcher().is_some()
2494    }
2495
2496    /// Whether the root gate is due at this batch's canonical block (§6.2):
2497    /// the first canonical block ever seen always fires (baseline adoption
2498    /// must not wait a full window), then at most once every `n` blocks.
2499    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
2500        let Some(block) = canonical_block else {
2501            return false;
2502        };
2503        match self.root_gate_cadence {
2504            RootGateCadence::Disabled => false,
2505            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
2506                None => true,
2507                Some(last) => block >= last.saturating_add(n.get()),
2508            },
2509        }
2510    }
2511
2512    /// The `storageHash` root gate (Phase-8 step 4), fired per
2513    /// [`RootGateCadence`] window (§6.2).
2514    ///
2515    /// Runs at the firing batch's canonical block, with `touched` carrying the
2516    /// union of decoder-touched addresses since the previous firing. For each tracked
2517    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
2518    /// account, probe the root (and account fields) via the account-proof seam and
2519    /// apply the spec §4 table:
2520    ///
2521    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
2522    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
2523    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
2524    ///   ⇒ a decoder covered it; re-adopt, no gap.
2525    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
2526    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
2527    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
2528    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
2529    ///   the baseline (native field changes never move the storage root); on a move
2530    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
2531    ///   resync for the changed fields and re-adopt.
2532    ///
2533    /// No-op when the tracking registry is empty, when the batch has no canonical
2534    /// block, or when the cache has no account-proof fetcher installed.
2535    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
2536    /// Decision 3).
2537    fn run_root_gate(
2538        &mut self,
2539        cache: &EvmCache,
2540        canonical_block: Option<u64>,
2541        touched: &HashSet<Address>,
2542        resyncs: &mut Vec<ResyncRequest>,
2543        reports: &mut Vec<Arc<ReactiveReport<N>>>,
2544    ) {
2545        if self.tracking.is_empty() {
2546            return;
2547        }
2548        let Some(block) = canonical_block else {
2549            return;
2550        };
2551        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
2552            return;
2553        };
2554
2555        // Collect the root-gated targets (Slots opts out) in a stable order so a
2556        // single-block sequence of resyncs/reports is deterministic.
2557        let mut targets: Vec<(Address, bool)> = self
2558            .tracking
2559            .iter()
2560            .filter_map(|(address, policy)| match policy {
2561                TrackingPolicy::Slots { .. } => None,
2562                TrackingPolicy::WholeAccount => Some((*address, true)),
2563                TrackingPolicy::Scalars => Some((*address, false)),
2564            })
2565            .collect();
2566        if targets.is_empty() {
2567            return;
2568        }
2569        targets.sort_by_key(|(address, _)| *address);
2570
2571        let block_id = BlockId::number(block);
2572        // ONE seam invocation carries every root-gated target (root-only
2573        // probes: no storage keys needed). eth_getProof is single-address at
2574        // the RPC level, so batching here lets the fetcher fan the requests
2575        // out concurrently instead of paying N sequential round trips.
2576        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
2577            targets
2578                .iter()
2579                .map(|&(address, _)| (address, vec![]))
2580                .collect(),
2581            block_id,
2582        )
2583        .into_iter()
2584        .collect();
2585        for (address, whole_account) in targets {
2586            let Some(Ok(proof)) = probes.remove(&address) else {
2587                // A failed/omitted probe carries no signal; leave the baseline
2588                // untouched and try again next block.
2589                continue;
2590            };
2591
2592            let baseline = self.tracked_roots.get(&address).cloned();
2593            let Some(baseline) = baseline else {
2594                // First observation: adopt the baseline. Not a coverage gap.
2595                self.adopt_root(address, block, &proof);
2596                continue;
2597            };
2598
2599            // A stale probe (a batch whose canonical block is not newer than the
2600            // last one we baselined this account against) carries no forward
2601            // signal: skip it rather than diff against — or clobber — a newer
2602            // baseline.
2603            if block <= baseline.last_block {
2604                continue;
2605            }
2606
2607            if whole_account {
2608                if proof.storage_hash == baseline.last_root {
2609                    // Tight steady-state path: unchanged root ⇒ nothing.
2610                    continue;
2611                }
2612                // Root moved.
2613                if !touched.contains(&address) {
2614                    // Moved with no covering decoder — the coverage gap.
2615                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
2616                        address,
2617                        block,
2618                        _network: PhantomData,
2619                    })));
2620                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
2621                    resyncs.push(root_moved_account_resync(
2622                        address,
2623                        block,
2624                        AccountFieldMask {
2625                            balance: true,
2626                            nonce: true,
2627                            code: true,
2628                        },
2629                    ));
2630                }
2631                // Adopt the new root whether or not a decoder covered it.
2632                self.adopt_root(address, block, &proof);
2633            } else {
2634                // Scalars: compare the account fields directly (native changes do
2635                // not move the storage root).
2636                let balance_moved = proof.balance != baseline.balance;
2637                let nonce_moved = proof.nonce != baseline.nonce;
2638                let code_moved = proof.code_hash != baseline.code_hash;
2639                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
2640                    resyncs.push(root_moved_account_resync(
2641                        address,
2642                        block,
2643                        AccountFieldMask {
2644                            balance: balance_moved,
2645                            nonce: nonce_moved,
2646                            code: code_moved,
2647                        },
2648                    ));
2649                }
2650                self.adopt_root(address, block, &proof);
2651            }
2652        }
2653    }
2654
2655    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
2656    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
2657        self.tracked_roots.insert(
2658            address,
2659            TrackedRoot {
2660                last_root: proof.storage_hash,
2661                last_block: block,
2662                balance: proof.balance,
2663                nonce: proof.nonce,
2664                code_hash: proof.code_hash,
2665            },
2666        );
2667    }
2668
2669    fn execute_handlers(
2670        &self,
2671        cache: &EvmCache,
2672        record: &ReactiveInputRecord<N>,
2673        input_ref: InputRef,
2674    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
2675        let mut executions = Vec::new();
2676        let candidates: Vec<_> = match &record.input {
2677            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
2678            ReactiveInput::BlockHeader(_)
2679            | ReactiveInput::FullBlock(_)
2680            | ReactiveInput::PendingTxHash(_)
2681            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
2682        };
2683        for registered in candidates {
2684            if !registered.matches(&record.input) {
2685                continue;
2686            }
2687
2688            let outcome = registered
2689                .handler
2690                .handle(&record.context, &record.input, cache)
2691                .map_err(|source| ReactiveError::HandlerFailed {
2692                    handler_id: registered.id.clone(),
2693                    source,
2694                })?;
2695
2696            if let Err(error) =
2697                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
2698            {
2699                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
2700                    self.metrics
2701                        .pending_contamination
2702                        .fetch_add(1, Ordering::Relaxed);
2703                }
2704                return Err(error);
2705            }
2706            executions.push(HandlerExecution::from_outcome(
2707                registered.id.clone(),
2708                input_ref,
2709                outcome,
2710            ));
2711        }
2712        Ok(executions)
2713    }
2714
2715    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
2716        for report in reports {
2717            for hook in &self.hooks {
2718                hook.on_report(report.clone());
2719            }
2720        }
2721    }
2722
2723    fn recover_for_canonical_input(
2724        &mut self,
2725        cache: &mut EvmCache,
2726        record: &ReactiveInputRecord<N>,
2727        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
2728    ) -> Option<ReorgReport<N>> {
2729        let block = canonical_record_block(record)?;
2730        let latest = self.journal.back()?.block.clone();
2731
2732        if self
2733            .journal
2734            .iter()
2735            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
2736        {
2737            return None;
2738        }
2739
2740        if block.number == latest.number.saturating_add(1) && block.parent_hash == Some(latest.hash)
2741        {
2742            return None;
2743        }
2744
2745        if block.number > latest.number.saturating_add(1) {
2746            // A forward gap: blocks between the journaled head and the arriving
2747            // block were never observed (e.g. a disconnect). Make it observable
2748            // and escalate health, but still accept the arriving block so it
2749            // journals/applies normally (the chain extends).
2750            self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
2751            health_reports.extend(self.escalate_trust(block.number));
2752            health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
2753                MissedRangeReport {
2754                    from: latest.number + 1,
2755                    to: block.number - 1,
2756                    block: block.number,
2757                    _network: PhantomData,
2758                },
2759            )));
2760            return None;
2761        }
2762
2763        let dropped = if let Some(parent_hash) = block.parent_hash {
2764            if let Some(parent_index) = self
2765                .journal
2766                .iter()
2767                .rposition(|entry| entry.block.hash == parent_hash)
2768            {
2769                self.drain_journal_after(parent_index)
2770            } else {
2771                health_reports.extend(self.warn_under_recovery(block.number));
2772                self.drain_journal_from_number(block.number)
2773            }
2774        } else {
2775            health_reports.extend(self.warn_under_recovery(block.number));
2776            self.drain_journal_from_number(block.number)
2777        };
2778
2779        self.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
2780    }
2781
2782    fn recover_for_reorged_input(
2783        &mut self,
2784        cache: &mut EvmCache,
2785        record: &ReactiveInputRecord<N>,
2786        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
2787    ) -> Option<ReorgReport<N>> {
2788        let (dropped_block, reason) = reorg_signal_block(record)?;
2789        let dropped = if let Some(index) = self
2790            .journal
2791            .iter()
2792            .position(|entry| entry.block.hash == dropped_block.hash)
2793        {
2794            self.drain_journal_from(index)
2795        } else {
2796            health_reports.extend(self.warn_under_recovery(dropped_block.number));
2797            self.drain_journal_from_number(dropped_block.number)
2798        };
2799
2800        if dropped.is_empty() {
2801            let canceled_resyncs =
2802                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
2803            if canceled_resyncs.is_empty() {
2804                return None;
2805            }
2806            return Some(ReorgReport {
2807                dropped: Some(dropped_block.clone()),
2808                dropped_blocks: vec![dropped_block],
2809                dropped_inputs: Vec::new(),
2810                rollback_updates: Vec::new(),
2811                rollback_diff: StateDiff::default(),
2812                purge_updates: Vec::new(),
2813                purge_diff: StateDiff::default(),
2814                canceled_resyncs,
2815                reason,
2816                _network: PhantomData,
2817            });
2818        }
2819
2820        self.recover_dropped_journals(cache, dropped, reason)
2821    }
2822
2823    /// Warn that a reorg references a block no longer resident in the journal, so
2824    /// recovery is limited to the blocks still journaled — effects from aged-out
2825    /// blocks are neither rolled back nor purged (the freshness/validation loop is
2826    /// the backstop). Makes the under-recovery observable instead of silent.
2827    ///
2828    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
2829    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
2830    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
2831    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
2832    /// transition is returned so the caller can thread it into the ingest cycle's
2833    /// dispatched reports.
2834    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
2835        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
2836        tracing::warn!(
2837            reorg_block = reorg_number,
2838            oldest_journaled = ?oldest_journaled,
2839            journal_depth = self.config.journal_depth,
2840            "reactive reorg recovery is incomplete: the reorged block is no longer \
2841             in the journal, so effects from blocks aged out of the journal are \
2842             neither rolled back nor purged (the freshness/validation loop is the \
2843             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
2844             reorgs precisely."
2845        );
2846
2847        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
2848
2849        self.escalate_trust(reorg_number)
2850    }
2851
2852    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
2853        let entry = self.journal_entry_mut(block);
2854        if !entry.inputs.contains(&input_ref) {
2855            entry.inputs.push(input_ref);
2856        }
2857        self.trim_journal();
2858    }
2859
2860    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
2861        self.journal_entry_mut(block).applied.push(applied);
2862        self.trim_journal();
2863    }
2864
2865    fn record_journal_resync(&mut self, report: &ResyncReport) {
2866        if report.diff.is_empty() {
2867            return;
2868        }
2869        let Some(block) = single_hash_pinned_resync_block(report) else {
2870            return;
2871        };
2872        self.journal_entry_mut(&block).resynced.push(report.clone());
2873        self.trim_journal();
2874    }
2875
2876    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
2877        if let Some(index) = self
2878            .journal
2879            .iter()
2880            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
2881        {
2882            return &mut self.journal[index];
2883        }
2884
2885        self.journal.push_back(BlockJournal {
2886            block: block.clone(),
2887            inputs: Vec::new(),
2888            applied: Vec::new(),
2889            resynced: Vec::new(),
2890        });
2891        let index = self.journal.len() - 1;
2892        &mut self.journal[index]
2893    }
2894
2895    fn trim_journal(&mut self) {
2896        if self.config.journal_depth == 0 {
2897            self.journal.clear();
2898            return;
2899        }
2900        while self.journal.len() > self.config.journal_depth {
2901            self.journal.pop_front();
2902        }
2903    }
2904
2905    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
2906        self.journal.drain((index + 1)..).collect()
2907    }
2908
2909    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
2910        self.journal.drain(index..).collect()
2911    }
2912
2913    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
2914        let Some(index) = self
2915            .journal
2916            .iter()
2917            .position(|entry| entry.block.number >= number)
2918        else {
2919            return Vec::new();
2920        };
2921        self.drain_journal_from(index)
2922    }
2923
2924    fn recover_dropped_journals(
2925        &mut self,
2926        cache: &mut EvmCache,
2927        dropped: Vec<BlockJournal<N>>,
2928        reason: ReorgReason,
2929    ) -> Option<ReorgReport<N>> {
2930        if dropped.is_empty() {
2931            return None;
2932        }
2933
2934        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block.clone()).collect();
2935        let dropped_inputs: Vec<_> = dropped
2936            .iter()
2937            .flat_map(|entry| entry.inputs.iter().copied())
2938            .collect();
2939        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
2940        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
2941        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
2942        let purge_updates: Vec<_> = purge_scopes
2943            .iter()
2944            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
2945            .collect();
2946
2947        let rollback_diff = if rollback_updates.is_empty() {
2948            StateDiff::default()
2949        } else {
2950            cache.apply_updates(&rollback_updates)
2951        };
2952        let purge_diff = if purge_updates.is_empty() {
2953            StateDiff::default()
2954        } else {
2955            cache.apply_updates(&purge_updates)
2956        };
2957
2958        Some(ReorgReport {
2959            dropped: dropped_blocks.first().cloned(),
2960            dropped_blocks,
2961            dropped_inputs,
2962            rollback_updates,
2963            rollback_diff,
2964            purge_updates,
2965            purge_diff,
2966            canceled_resyncs,
2967            reason,
2968            _network: PhantomData,
2969        })
2970    }
2971
2972    fn cancel_resyncs_for_dropped_blocks(
2973        &mut self,
2974        dropped_blocks: &[BlockRef],
2975    ) -> Vec<ResyncRequest> {
2976        let mut canceled = Vec::new();
2977        self.pending_resyncs.retain(|request| {
2978            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
2979            if should_cancel {
2980                canceled.push(request.clone());
2981            }
2982            !should_cancel
2983        });
2984        canceled
2985    }
2986
2987    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
2988        let ids: HashSet<_> = ids.into_iter().cloned().collect();
2989        self.pending_resyncs
2990            .retain(|request| !ids.contains(&request.id));
2991    }
2992}
2993
2994/// Fold every address a [`StateDiff`] references — genuine changes
2995/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
2996/// into `into`. Used by the per-block root gate to accumulate the batch's
2997/// decoder-touched address set: an account a decoder wrote (or tried to write) is
2998/// "covered," so a subsequent root move for it is not a coverage gap.
2999fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
3000    into.extend(diff.slots.iter().map(|change| change.address));
3001    into.extend(diff.accounts.iter().map(|change| change.address));
3002    into.extend(diff.purged.iter().map(|purge| purge.address));
3003    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
3004    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
3005    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
3006    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
3007}
3008
3009/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
3010/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
3011/// existing account-resync path (Wave 2). The id is derived from the address and
3012/// block so a repeated move on the same account/block coalesces deterministically.
3013fn root_moved_account_resync(
3014    address: Address,
3015    block: u64,
3016    fields: AccountFieldMask,
3017) -> ResyncRequest {
3018    ResyncRequest {
3019        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
3020        reason: ResyncReason::RootMoved,
3021        block: ResyncBlock::Number(block),
3022        targets: vec![ResyncTarget::Account { address, fields }],
3023        priority: ResyncPriority::Normal,
3024    }
3025}
3026
3027fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
3028    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
3029        return None;
3030    }
3031    if is_canonical_status(&record.context.chain_status) {
3032        return context_block_ref(&record.context);
3033    }
3034    None
3035}
3036
3037/// Best-effort per-block env refresh (Phase-8 step 2).
3038///
3039/// For a canonical record carrying a full header — a
3040/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
3041/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
3042/// `Some(result)` when a header was present (so the caller can surface a strict
3043/// validation error), and `None` for pending/reorged records or non-header
3044/// inputs, which must never drive a canonical env refresh.
3045fn advance_block_for_canonical_record<N: Network>(
3046    cache: &mut EvmCache,
3047    record: &ReactiveInputRecord<N>,
3048) -> Option<Result<(), BlockContextError>> {
3049    if !is_canonical_status(&record.context.chain_status) {
3050        return None;
3051    }
3052    match &record.input {
3053        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
3054        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
3055        _ => None,
3056    }
3057}
3058
3059fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
3060    match &ctx.chain_status {
3061        ChainStatus::Included { block, .. }
3062        | ChainStatus::Safe { block }
3063        | ChainStatus::Finalized { block } => Some(block),
3064        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
3065        ChainStatus::Pending => ctx.block.as_ref(),
3066    }
3067}
3068
3069fn reorg_signal_block<N: Network>(
3070    record: &ReactiveInputRecord<N>,
3071) -> Option<(BlockRef, ReorgReason)> {
3072    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
3073        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
3074    }
3075
3076    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
3077        return Some((dropped_from.clone(), ReorgReason::ReorgedInput));
3078    }
3079
3080    None
3081}
3082
3083fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
3084    context_block_ref(&record.context)
3085        .cloned()
3086        .or_else(|| match &record.input {
3087            ReactiveInput::Log(log) => Some(BlockRef {
3088                number: log.block_number?,
3089                hash: log.block_hash?,
3090                parent_hash: None,
3091                timestamp: log.block_timestamp,
3092            }),
3093            ReactiveInput::BlockHeader(header) => Some(BlockRef {
3094                number: header.number(),
3095                hash: header.hash(),
3096                parent_hash: Some(header.parent_hash()),
3097                timestamp: Some(header.timestamp()),
3098            }),
3099            ReactiveInput::FullBlock(block) => {
3100                let header = block.header();
3101                Some(BlockRef {
3102                    number: header.number(),
3103                    hash: header.hash(),
3104                    parent_hash: Some(header.parent_hash()),
3105                    timestamp: Some(header.timestamp()),
3106                })
3107            }
3108            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
3109        })
3110}
3111
3112fn remove_canceled_resyncs_from_batch(
3113    resyncs: &mut Vec<ResyncRequest>,
3114    canceled: &[ResyncRequest],
3115) {
3116    if canceled.is_empty() {
3117        return;
3118    }
3119    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
3120    resyncs.retain(|request| !canceled_ids.contains(&request.id));
3121}
3122
3123fn resync_target_address(target: &ResyncTarget) -> Address {
3124    match target {
3125        ResyncTarget::StorageSlot { address, .. }
3126        | ResyncTarget::StorageSlots { address, .. }
3127        | ResyncTarget::Account { address, .. } => *address,
3128    }
3129}
3130
3131fn resync_request_targets_dropped_block(
3132    request: &ResyncRequest,
3133    dropped_blocks: &[BlockRef],
3134) -> bool {
3135    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
3136        return false;
3137    };
3138    dropped_blocks
3139        .iter()
3140        .any(|block| block.hash == *hash && block.number == *number)
3141}
3142
3143fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
3144    let first = report.requested.first()?.block.clone();
3145    if !report
3146        .requested
3147        .iter()
3148        .all(|request| request.block == first)
3149    {
3150        return None;
3151    }
3152
3153    let ResyncBlock::Hash { number, hash, .. } = first else {
3154        return None;
3155    };
3156
3157    Some(BlockRef {
3158        number,
3159        hash,
3160        parent_hash: None,
3161        timestamp: None,
3162    })
3163}
3164
3165fn purge_scopes_for_dropped_journals<N: Network>(
3166    dropped: &[BlockJournal<N>],
3167) -> Vec<(Address, PurgeScope)> {
3168    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
3169    for entry in dropped.iter().rev() {
3170        for resynced in entry.resynced.iter().rev() {
3171            merge_purge_scopes_for_diff(&mut scopes, &resynced.diff);
3172        }
3173        for applied in entry.applied.iter().rev() {
3174            merge_purge_scopes_for_diff(&mut scopes, &applied.diff);
3175        }
3176    }
3177    scopes
3178}
3179
3180fn rollback_updates_for_dropped_journals<N: Network>(
3181    dropped: &[BlockJournal<N>],
3182    purge_scopes: &[(Address, PurgeScope)],
3183) -> Vec<StateUpdate> {
3184    let purge_addresses: HashSet<_> = purge_scopes
3185        .iter()
3186        .map(|(address, _scope)| *address)
3187        .collect();
3188    let mut updates = Vec::new();
3189    for entry in dropped.iter().rev() {
3190        for resynced in entry.resynced.iter().rev() {
3191            push_rollback_updates_for_diff(&mut updates, &resynced.diff, &purge_addresses);
3192        }
3193        for applied in entry.applied.iter().rev() {
3194            push_rollback_updates_for_diff(&mut updates, &applied.diff, &purge_addresses);
3195        }
3196    }
3197    updates
3198}
3199
3200fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
3201    for change in &diff.accounts {
3202        merge_purge_scope(scopes, change.address, PurgeScope::Account);
3203    }
3204    for record in &diff.purged {
3205        merge_purge_scope(scopes, record.address, record.scope.clone());
3206    }
3207}
3208
3209fn push_rollback_updates_for_diff(
3210    updates: &mut Vec<StateUpdate>,
3211    diff: &StateDiff,
3212    purge_addresses: &HashSet<Address>,
3213) {
3214    for change in diff.slots.iter().rev() {
3215        if purge_addresses.contains(&change.address) {
3216            continue;
3217        }
3218        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
3219    }
3220}
3221
3222fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
3223    if let Some((_existing_address, existing_scope)) = scopes
3224        .iter_mut()
3225        .find(|(existing_address, _scope)| *existing_address == address)
3226    {
3227        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
3228    } else {
3229        scopes.push((address, scope));
3230    }
3231}
3232
3233fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
3234    match (left, right) {
3235        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
3236        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
3237        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
3238            for slot in right {
3239                if !left.contains(&slot) {
3240                    left.push(slot);
3241                }
3242            }
3243            PurgeScope::Slots(left)
3244        }
3245    }
3246}
3247
3248#[derive(Clone, Debug)]
3249struct StorageFetchSlot {
3250    address: Address,
3251    slot: U256,
3252    origins: Vec<StorageFetchOrigin>,
3253}
3254
3255#[derive(Clone, Debug)]
3256struct StorageFetchOrigin {
3257    request_id: ResyncId,
3258    target: ResyncTarget,
3259}
3260
3261#[derive(Clone, Debug)]
3262struct StorageFetchGroup {
3263    block: ResyncBlock,
3264    slots: Vec<StorageFetchSlot>,
3265    seen: HashSet<(Address, U256)>,
3266}
3267
3268/// One account-target resync collected during request scanning, resolved through
3269/// the account proof fetcher after storage groups are processed.
3270#[derive(Clone, Debug)]
3271struct AccountResyncTarget {
3272    request_id: ResyncId,
3273    block: ResyncBlock,
3274    address: Address,
3275    fields: AccountFieldMask,
3276}
3277
3278fn resolve_trace_resyncs(
3279    cache: &EvmCache,
3280    storage_groups: &mut Vec<StorageFetchGroup>,
3281    account_targets: &mut Vec<AccountResyncTarget>,
3282    state_updates: &mut Vec<StateUpdate>,
3283) {
3284    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
3285        return;
3286    };
3287
3288    let mut blocks = Vec::new();
3289    let mut seen = HashSet::new();
3290    for block in storage_groups
3291        .iter()
3292        .map(|group| group.block.clone())
3293        .chain(account_targets.iter().map(|target| target.block.clone()))
3294    {
3295        if seen.insert(block.clone()) {
3296            blocks.push(block);
3297        }
3298    }
3299
3300    let mut traces = HashMap::new();
3301    for block in blocks {
3302        match (fetcher)(resync_block_to_block_id(&block)) {
3303            Ok(diff) => {
3304                traces.insert(block, diff);
3305            }
3306            Err(error) => {
3307                tracing::debug!(
3308                    block = ?block,
3309                    error = %error,
3310                    "block trace resync source failed; falling back to point resync"
3311                );
3312            }
3313        }
3314    }
3315
3316    for group in storage_groups.iter_mut() {
3317        let Some(trace) = traces.get(&group.block) else {
3318            continue;
3319        };
3320        group.slots.retain(|slot| {
3321            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
3322                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
3323                return false;
3324            }
3325            cache
3326                .cached_storage_value(slot.address, slot.slot)
3327                .is_none()
3328        });
3329        group.seen = group
3330            .slots
3331            .iter()
3332            .map(|slot| (slot.address, slot.slot))
3333            .collect();
3334    }
3335    storage_groups.retain(|group| !group.slots.is_empty());
3336
3337    let mut unresolved_accounts = Vec::new();
3338    for mut account in account_targets.drain(..) {
3339        let Some(trace) = traces.get(&account.block) else {
3340            unresolved_accounts.push(account);
3341            continue;
3342        };
3343        let Some(trace_account) = trace
3344            .accounts
3345            .iter()
3346            .find(|diff| diff.address == account.address)
3347        else {
3348            unresolved_accounts.push(account);
3349            continue;
3350        };
3351
3352        let mut patch = AccountPatch::default();
3353        let mut unresolved = AccountFieldMask::default();
3354        if account.fields.balance {
3355            if let Some(balance) = trace_account.balance {
3356                patch = patch.balance(balance);
3357            } else {
3358                unresolved.balance = true;
3359            }
3360        }
3361        if account.fields.nonce {
3362            if let Some(nonce) = trace_account.nonce {
3363                patch = patch.nonce(nonce);
3364            } else {
3365                unresolved.nonce = true;
3366            }
3367        }
3368        if account.fields.code {
3369            if let Some(code) = &trace_account.code {
3370                patch = patch.code(code.clone());
3371            } else {
3372                unresolved.code = true;
3373            }
3374        }
3375
3376        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
3377            state_updates.push(StateUpdate::account_upsert(account.address, patch));
3378        }
3379        if !account_field_mask_empty(unresolved) {
3380            account.fields = unresolved;
3381            unresolved_accounts.push(account);
3382        }
3383    }
3384    *account_targets = unresolved_accounts;
3385}
3386
3387fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
3388    trace
3389        .accounts
3390        .iter()
3391        .find(|account| account.address == address)
3392        .and_then(|account| {
3393            account
3394                .storage
3395                .iter()
3396                .find(|entry| entry.slot == slot)
3397                .map(|entry| entry.value)
3398        })
3399}
3400
3401fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
3402    !mask.balance && !mask.nonce && !mask.code
3403}
3404
3405fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
3406    let mut failed = Vec::new();
3407    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
3408    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
3409
3410    for request in requests {
3411        for target in &request.targets {
3412            match target {
3413                ResyncTarget::StorageSlot { address, slot } => {
3414                    push_storage_resync_slot(
3415                        &mut storage_groups,
3416                        &request.id,
3417                        &request.block,
3418                        *address,
3419                        *slot,
3420                    );
3421                }
3422                ResyncTarget::StorageSlots { address, slots } => {
3423                    for slot in slots {
3424                        push_storage_resync_slot(
3425                            &mut storage_groups,
3426                            &request.id,
3427                            &request.block,
3428                            *address,
3429                            *slot,
3430                        );
3431                    }
3432                }
3433                ResyncTarget::Account { address, fields } => {
3434                    account_targets.push(AccountResyncTarget {
3435                        request_id: request.id.clone(),
3436                        block: request.block.clone(),
3437                        address: *address,
3438                        fields: *fields,
3439                    });
3440                }
3441            }
3442        }
3443    }
3444
3445    let mut state_updates = Vec::new();
3446    resolve_trace_resyncs(
3447        cache,
3448        &mut storage_groups,
3449        &mut account_targets,
3450        &mut state_updates,
3451    );
3452
3453    if !storage_groups.is_empty() {
3454        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
3455            for group in storage_groups {
3456                let block = group.block.clone();
3457                let fetches: Vec<(Address, U256)> = group
3458                    .slots
3459                    .iter()
3460                    .map(|slot| (slot.address, slot.slot))
3461                    .collect();
3462                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
3463                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
3464                    .slots
3465                    .iter()
3466                    .cloned()
3467                    .map(|slot| ((slot.address, slot.slot), slot))
3468                    .collect();
3469
3470                for (address, slot, fetched) in results {
3471                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
3472                        continue;
3473                    };
3474                    match fetched {
3475                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
3476                        Err(error) => {
3477                            let message = error.to_string();
3478                            push_resync_failures(
3479                                &mut failed,
3480                                &block,
3481                                requested_slot.origins,
3482                                ResyncFailureKind::StorageFetchFailed,
3483                                message,
3484                            );
3485                        }
3486                    }
3487                }
3488
3489                for requested_slot in group.slots {
3490                    if pending
3491                        .remove(&(requested_slot.address, requested_slot.slot))
3492                        .is_some()
3493                    {
3494                        push_resync_failures(
3495                            &mut failed,
3496                            &block,
3497                            requested_slot.origins,
3498                            ResyncFailureKind::StorageFetchOmitted,
3499                            "storage batch fetcher did not return a value for slot".to_string(),
3500                        );
3501                    }
3502                }
3503            }
3504        } else {
3505            for group in storage_groups {
3506                let block = group.block.clone();
3507                for slot in group.slots {
3508                    push_resync_failures(
3509                        &mut failed,
3510                        &block,
3511                        slot.origins,
3512                        ResyncFailureKind::MissingStorageFetcher,
3513                        "storage resync requires a storage batch fetcher".to_string(),
3514                    );
3515                }
3516            }
3517        }
3518    }
3519
3520    if !account_targets.is_empty() {
3521        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
3522            // ONE seam invocation per distinct resync block (targets may pin
3523            // different blocks): eth_getProof is single-address at the RPC
3524            // level, so batching the addresses lets the fetcher fan the
3525            // requests out concurrently instead of paying one round trip per
3526            // account. Root-only probes: account fields need no storage keys.
3527            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
3528            for account in account_targets {
3529                let block_id = resync_block_to_block_id(&account.block);
3530                match groups
3531                    .iter_mut()
3532                    .find(|(group_block, _)| *group_block == block_id)
3533                {
3534                    Some((_, group)) => group.push(account),
3535                    None => groups.push((block_id, vec![account])),
3536                }
3537            }
3538            for (block_id, group) in groups {
3539                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
3540                    group
3541                        .iter()
3542                        .map(|account| (account.address, vec![]))
3543                        .collect(),
3544                    block_id,
3545                )
3546                .into_iter()
3547                .collect();
3548                for account in group {
3549                    // `get` + clone rather than `remove`: two targets for the
3550                    // same address in one group must both resolve from the
3551                    // single probe.
3552                    match probes.get(&account.address).cloned() {
3553                        Some(Ok(proof)) => {
3554                            // Build an authoritative account update from the requested
3555                            // field mask. Use the MATERIALIZING `account_upsert` so a
3556                            // resync applies even to a cold account (a partial `Account`
3557                            // patch on a cold address is silently skipped).
3558                            let mut patch = AccountPatch::default();
3559                            if account.fields.balance {
3560                                patch = patch.balance(proof.balance);
3561                            }
3562                            if account.fields.nonce {
3563                                patch = patch.nonce(proof.nonce);
3564                            }
3565                            // Note: `AccountProof` carries `code_hash`, not code bytes;
3566                            // the `eth_getProof` seam cannot supply runtime code, so a
3567                            // code-field resync is a no-op here (code freshness is
3568                            // handled by a later wave). We still materialize the account
3569                            // so requested balance/nonce fields take effect.
3570                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
3571                        }
3572                        Some(Err(error)) => {
3573                            failed.push(ResyncFailure {
3574                                request_id: account.request_id,
3575                                block: account.block,
3576                                target: ResyncTarget::Account {
3577                                    address: account.address,
3578                                    fields: account.fields,
3579                                },
3580                                kind: ResyncFailureKind::AccountFetchFailed,
3581                                message: error.to_string(),
3582                            });
3583                        }
3584                        None => {
3585                            failed.push(ResyncFailure {
3586                                request_id: account.request_id,
3587                                block: account.block,
3588                                target: ResyncTarget::Account {
3589                                    address: account.address,
3590                                    fields: account.fields,
3591                                },
3592                                kind: ResyncFailureKind::AccountFetchOmitted,
3593                                message:
3594                                    "account proof fetcher did not return a result for address"
3595                                        .to_string(),
3596                            });
3597                        }
3598                    }
3599                }
3600            }
3601        } else {
3602            for account in account_targets {
3603                failed.push(ResyncFailure {
3604                    request_id: account.request_id,
3605                    block: account.block,
3606                    target: ResyncTarget::Account {
3607                        address: account.address,
3608                        fields: account.fields,
3609                    },
3610                    kind: ResyncFailureKind::MissingAccountFetcher,
3611                    message: "account resync requires an account proof fetcher".to_string(),
3612                });
3613            }
3614        }
3615    }
3616
3617    let diff = if state_updates.is_empty() {
3618        StateDiff::default()
3619    } else {
3620        cache.apply_updates(&state_updates)
3621    };
3622
3623    ResyncReport {
3624        requested: requests.to_vec(),
3625        state_updates,
3626        diff,
3627        failed,
3628    }
3629}
3630
3631fn push_resync_failures(
3632    failed: &mut Vec<ResyncFailure>,
3633    block: &ResyncBlock,
3634    origins: Vec<StorageFetchOrigin>,
3635    kind: ResyncFailureKind,
3636    message: String,
3637) {
3638    for origin in origins {
3639        failed.push(ResyncFailure {
3640            request_id: origin.request_id,
3641            block: block.clone(),
3642            target: origin.target,
3643            kind,
3644            message: message.clone(),
3645        });
3646    }
3647}
3648
3649fn push_storage_resync_slot(
3650    groups: &mut Vec<StorageFetchGroup>,
3651    request_id: &ResyncId,
3652    block: &ResyncBlock,
3653    address: Address,
3654    slot: U256,
3655) {
3656    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
3657        index
3658    } else {
3659        groups.push(StorageFetchGroup {
3660            block: block.clone(),
3661            slots: Vec::new(),
3662            seen: HashSet::new(),
3663        });
3664        groups.len() - 1
3665    };
3666
3667    let group = &mut groups[group_index];
3668    let origin = StorageFetchOrigin {
3669        request_id: request_id.clone(),
3670        target: ResyncTarget::StorageSlot { address, slot },
3671    };
3672    if group.seen.insert((address, slot)) {
3673        group.slots.push(StorageFetchSlot {
3674            address,
3675            slot,
3676            origins: vec![origin],
3677        });
3678    } else if let Some(existing) = group
3679        .slots
3680        .iter_mut()
3681        .find(|existing| existing.address == address && existing.slot == slot)
3682    {
3683        existing.origins.push(origin);
3684    }
3685}
3686
3687fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
3688    match block {
3689        ResyncBlock::Latest => BlockId::latest(),
3690        ResyncBlock::Safe => BlockId::safe(),
3691        ResyncBlock::Finalized => BlockId::finalized(),
3692        ResyncBlock::Number(number) => BlockId::number(*number),
3693        ResyncBlock::Hash {
3694            number: _,
3695            hash,
3696            require_canonical,
3697        } => BlockId::from((*hash, Some(*require_canonical))),
3698    }
3699}
3700
3701impl<N: Network> RegisteredHandler<N> {
3702    fn matches(&self, input: &ReactiveInput<N>) -> bool {
3703        self.interests
3704            .iter()
3705            .any(|interest| interest_matches(interest, input))
3706    }
3707
3708    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
3709        self.interests.iter().find_map(|interest| match interest {
3710            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
3711                handler_id: self.id.clone(),
3712                route_key: interest.route_key(log),
3713            }),
3714            ReactiveInterest::Logs(_)
3715            | ReactiveInterest::Blocks(_)
3716            | ReactiveInterest::PendingTransactions(_) => None,
3717        })
3718    }
3719}
3720
3721fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
3722    if let Some(existing) = filters
3723        .iter_mut()
3724        .find(|existing| existing.block_option == next.block_option)
3725    {
3726        merge_filter_set(&mut existing.address, &next.address);
3727        for (existing_topic, next_topic) in existing.topics.iter_mut().zip(next.topics.iter()) {
3728            merge_filter_set(existing_topic, next_topic);
3729        }
3730    } else {
3731        filters.push(next.clone());
3732    }
3733}
3734
3735fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
3736    if target.is_empty() {
3737        return;
3738    }
3739    if source.is_empty() {
3740        *target = FilterSet::default();
3741        return;
3742    }
3743    for value in source.iter() {
3744        target.insert(value.clone());
3745    }
3746}
3747
3748#[derive(Clone, Debug)]
3749struct HandlerExecution {
3750    handler_id: HandlerId,
3751    quality: StateEffectQuality,
3752    tags: Vec<ReportTag>,
3753    state_updates: Vec<StateUpdate>,
3754    invalidations: Vec<InvalidationRequest>,
3755    resyncs: Vec<ResyncRequest>,
3756    speculative: Vec<SpeculativeRequest>,
3757    hook_signals: Vec<HookSignal>,
3758}
3759
3760impl HandlerExecution {
3761    fn from_outcome(handler_id: HandlerId, input_ref: InputRef, outcome: HandlerOutcome) -> Self {
3762        let mut state_updates = Vec::new();
3763        let mut invalidations = Vec::new();
3764        let mut resyncs = Vec::new();
3765        let mut speculative = Vec::new();
3766        let mut hook_signals = Vec::new();
3767
3768        for effect in outcome.effects {
3769            match effect {
3770                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
3771                ReactiveEffect::Invalidate(invalidation) => {
3772                    state_updates.push(StateUpdate::purge(
3773                        invalidation.address,
3774                        invalidation.scope.clone(),
3775                    ));
3776                    invalidations.push(invalidation);
3777                }
3778                ReactiveEffect::Resync(request) => resyncs.push(request),
3779                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
3780                ReactiveEffect::Speculative(mut request) => {
3781                    request.input_ref = input_ref;
3782                    speculative.push(request);
3783                }
3784            }
3785        }
3786
3787        Self {
3788            handler_id,
3789            quality: outcome.quality,
3790            tags: outcome.tags,
3791            state_updates,
3792            invalidations,
3793            resyncs,
3794            speculative,
3795            hook_signals,
3796        }
3797    }
3798}
3799
3800fn dedupe_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
3801    let mut seen = HashSet::new();
3802    let mut deduped = Vec::with_capacity(records.len());
3803    for record in records {
3804        if seen.insert(record.input_ref()) {
3805            deduped.push(record);
3806        }
3807    }
3808    deduped
3809}
3810
3811fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
3812    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
3813        records.into_iter().enumerate().collect();
3814    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
3815    indexed.into_iter().map(|(_, record)| record).collect()
3816}
3817
3818fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
3819    if let ReactiveInput::Log(log) = &record.input
3820        && is_canonical_status(&record.context.chain_status)
3821        && !log.removed
3822    {
3823        return RecordSortKey {
3824            class: 0,
3825            block_number: log
3826                .block_number
3827                .or(record.context.block.as_ref().map(|block| block.number))
3828                .unwrap_or(u64::MAX),
3829            transaction_index: log
3830                .transaction_index
3831                .or(record.context.transaction_index)
3832                .unwrap_or(u64::MAX),
3833            log_index: log
3834                .log_index
3835                .or(record.context.log_index)
3836                .unwrap_or(u64::MAX),
3837            original_index: index,
3838        };
3839    }
3840
3841    RecordSortKey {
3842        class: 1,
3843        block_number: 0,
3844        transaction_index: 0,
3845        log_index: 0,
3846        original_index: index,
3847    }
3848}
3849
3850#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
3851struct RecordSortKey {
3852    class: u8,
3853    block_number: u64,
3854    transaction_index: u64,
3855    log_index: u64,
3856    original_index: usize,
3857}
3858
3859fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
3860    match (interest, input) {
3861        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
3862        (
3863            ReactiveInterest::Blocks(BlockInterest {
3864                mode: BlockInterestMode::Header,
3865            }),
3866            ReactiveInput::BlockHeader(_),
3867        ) => true,
3868        (
3869            ReactiveInterest::Blocks(BlockInterest {
3870                mode: BlockInterestMode::FullBlock,
3871            }),
3872            ReactiveInput::FullBlock(_),
3873        ) => true,
3874        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
3875            interest.matches_hash_only()
3876        }
3877        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
3878            interest.matches_tx(tx)
3879        }
3880        _ => false,
3881    }
3882}
3883
3884fn validate_effects(
3885    input_ref: InputRef,
3886    ctx: &ReactiveContext,
3887    handler_id: &HandlerId,
3888    effects: &[ReactiveEffect],
3889) -> Result<(), ReactiveError> {
3890    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
3891        || matches!(input_ref, InputRef::PendingTx { .. });
3892    if !pending {
3893        return Ok(());
3894    }
3895
3896    for effect in effects {
3897        let effect_kind = match effect {
3898            ReactiveEffect::StateUpdate(_) => Some("state_update"),
3899            ReactiveEffect::Invalidate(_) => Some("invalidate"),
3900            ReactiveEffect::Resync(_) => Some("resync"),
3901            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
3902        };
3903        if let Some(effect_kind) = effect_kind {
3904            return Err(ReactiveError::InvalidPendingEffect {
3905                input_ref: Box::new(input_ref),
3906                handler_id: handler_id.clone(),
3907                effect_kind,
3908            });
3909        }
3910    }
3911    Ok(())
3912}
3913
3914fn detect_conflicts(
3915    input_ref: InputRef,
3916    executions: &[HandlerExecution],
3917) -> Result<(), ReactiveError> {
3918    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
3919    for execution in executions {
3920        for update in &execution.state_updates {
3921            for (target, value) in absolute_writes(update) {
3922                if let Some((previous_value, previous_handler)) = writes.get(&target) {
3923                    if previous_value != &value {
3924                        return Err(ReactiveError::ConflictingEffects {
3925                            input_ref: Box::new(input_ref),
3926                            target: Box::new(target),
3927                            first: previous_handler.clone(),
3928                            second: execution.handler_id.clone(),
3929                        });
3930                    }
3931                } else {
3932                    writes.insert(target, (value, execution.handler_id.clone()));
3933                }
3934            }
3935        }
3936    }
3937    Ok(())
3938}
3939
3940fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
3941    match update {
3942        StateUpdate::Slot {
3943            address,
3944            slot,
3945            value,
3946        } => vec![(
3947            EffectTarget::StorageSlot {
3948                address: *address,
3949                slot: *slot,
3950            },
3951            AbsoluteValue::U256(*value),
3952        )],
3953        StateUpdate::SlotMasked {
3954            address,
3955            slot,
3956            mask,
3957            value,
3958        } => vec![(
3959            EffectTarget::MaskedStorageSlot {
3960                address: *address,
3961                slot: *slot,
3962                mask: *mask,
3963            },
3964            AbsoluteValue::U256(*value),
3965        )],
3966        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
3967            account_patch_writes(*address, patch)
3968        }
3969        StateUpdate::SlotDelta { .. }
3970        | StateUpdate::BalanceDelta { .. }
3971        | StateUpdate::Purge { .. } => Vec::new(),
3972    }
3973}
3974
3975fn account_patch_writes(
3976    address: Address,
3977    patch: &AccountPatch,
3978) -> Vec<(EffectTarget, AbsoluteValue)> {
3979    let mut writes = Vec::new();
3980    if let Some(balance) = patch.balance {
3981        writes.push((
3982            EffectTarget::AccountBalance { address },
3983            AbsoluteValue::U256(balance),
3984        ));
3985    }
3986    if let Some(nonce) = patch.nonce {
3987        writes.push((
3988            EffectTarget::AccountNonce { address },
3989            AbsoluteValue::U64(nonce),
3990        ));
3991    }
3992    if let Some(code) = &patch.code {
3993        writes.push((
3994            EffectTarget::AccountCode { address },
3995            AbsoluteValue::Bytes(code.clone()),
3996        ));
3997    }
3998    writes
3999}
4000
4001fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
4002    match input {
4003        ReactiveInput::Log(log) => InputRef::Log {
4004            chain_id: ctx.chain_id,
4005            block_hash: log
4006                .block_hash
4007                .or(ctx.block.as_ref().map(|block| block.hash))
4008                .unwrap_or_default(),
4009            transaction_hash: log.transaction_hash.unwrap_or_default(),
4010            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
4011        },
4012        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
4013            chain_id: ctx.chain_id,
4014            hash: *hash,
4015        },
4016        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
4017            chain_id: ctx.chain_id,
4018            hash: tx.tx_hash(),
4019        },
4020        ReactiveInput::BlockHeader(header) => InputRef::Block {
4021            chain_id: ctx.chain_id,
4022            hash: header.hash(),
4023            number: header.number(),
4024        },
4025        ReactiveInput::FullBlock(block) => {
4026            let header = block.header();
4027            InputRef::Block {
4028                chain_id: ctx.chain_id,
4029                hash: header.hash(),
4030                number: header.number(),
4031            }
4032        }
4033    }
4034}
4035
4036fn is_canonical_status(status: &ChainStatus) -> bool {
4037    matches!(
4038        status,
4039        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
4040    )
4041}
4042
4043/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
4044pub struct EventDecoderHandler {
4045    id: HandlerId,
4046    decoder: Arc<dyn EventDecoder>,
4047    interest: LogInterest,
4048}
4049
4050impl EventDecoderHandler {
4051    /// Create an adapter from a decoder and log interest.
4052    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
4053        Self {
4054            id,
4055            decoder,
4056            interest,
4057        }
4058    }
4059}
4060
4061impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
4062    fn id(&self) -> HandlerId {
4063        self.id.clone()
4064    }
4065
4066    fn interests(&self) -> Vec<ReactiveInterest<N>> {
4067        vec![ReactiveInterest::Logs(self.interest.clone())]
4068    }
4069
4070    fn handle(
4071        &self,
4072        _ctx: &ReactiveContext,
4073        input: &ReactiveInput<N>,
4074        state: &dyn StateView,
4075    ) -> Result<HandlerOutcome, HandlerError> {
4076        let ReactiveInput::Log(log) = input else {
4077            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
4078        };
4079
4080        Ok(HandlerOutcome {
4081            effects: self
4082                .decoder
4083                .decode(&log.inner, state)
4084                .into_iter()
4085                .map(ReactiveEffect::StateUpdate)
4086                .collect(),
4087            quality: StateEffectQuality::ExactFromInput,
4088            tags: Vec::new(),
4089        })
4090    }
4091}
4092
4093/// Provider-agnostic subscriber interface.
4094pub trait EventSubscriber<N: Network = Ethereum>: Send {
4095    /// Replace all interests registered with the subscriber.
4096    ///
4097    /// Implementations may use this as a full setup/reset operation. The
4098    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
4099    /// delivery/dedupe bookkeeping when this method is called.
4100    fn register_interests(
4101        &mut self,
4102        interests: &[ReactiveInterest<N>],
4103    ) -> Result<(), SubscriberError>;
4104
4105    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
4106    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
4107}
4108
4109/// Boxed future returned by [`EventSubscriber::next_batch`].
4110pub type SubscriberNextBatch<'a, N> = Pin<
4111    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
4112>;
4113
4114/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
4115pub type SubscriberNextScopedBatch<'a, N> = Pin<
4116    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
4117>;
4118
4119/// Subscriber mode requested for the Alloy subscriber.
4120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
4121pub enum SubscriberMode {
4122    /// Prefer the default compiled transport.
4123    ///
4124    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
4125    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
4126    /// the opt-in `reactive-polling` feature is enabled.
4127    #[default]
4128    Auto,
4129    /// Use provider pubsub streams.
4130    PubSub,
4131    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
4132    Polling,
4133}
4134
4135/// Subscriber configuration.
4136#[derive(Clone, Debug, PartialEq, Eq)]
4137pub struct SubscriberConfig {
4138    /// Hydrate pending transaction hashes into full bodies when possible.
4139    pub hydrate_pending_transactions: bool,
4140    /// Maximum records to emit per batch.
4141    pub max_batch_size: usize,
4142    /// Maximum distinct contract addresses placed in one provider-side log
4143    /// subscription. Compatible logical owner filters are fanned into address
4144    /// supersets up to this limit; exact owner routing still happens locally.
4145    pub max_log_addresses_per_subscription: usize,
4146    /// Reconnect policy for WebSocket/pubsub streams.
4147    pub reconnect: SubscriberReconnectConfig,
4148}
4149
4150impl Default for SubscriberConfig {
4151    fn default() -> Self {
4152        Self {
4153            hydrate_pending_transactions: false,
4154            max_batch_size: 1024,
4155            max_log_addresses_per_subscription: 1024,
4156            reconnect: SubscriberReconnectConfig::default(),
4157        }
4158    }
4159}
4160
4161/// WebSocket/pubsub reconnect policy.
4162///
4163/// Reconnects are applied after an established subscription stream terminates.
4164/// Initial subscription failures are still returned immediately so deployment
4165/// mistakes, unsupported transports, and bad endpoints fail fast.
4166#[derive(Clone, Debug, PartialEq, Eq)]
4167pub struct SubscriberReconnectConfig {
4168    /// Whether pubsub streams should be recreated after termination.
4169    pub enabled: bool,
4170    /// Delay before the first reconnect attempt.
4171    pub initial_delay: Duration,
4172    /// Delay before the second reconnect attempt. Later retries double this
4173    /// delay up to [`Self::max_delay`].
4174    pub retry_delay: Duration,
4175    /// Maximum delay between reconnect attempts.
4176    pub max_delay: Duration,
4177    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
4178    pub max_attempts: Option<usize>,
4179    /// Number of recently emitted canonical input refs remembered to suppress
4180    /// duplicates across reconnect backfill and subscription replay.
4181    pub dedupe_window: usize,
4182}
4183
4184impl Default for SubscriberReconnectConfig {
4185    fn default() -> Self {
4186        Self {
4187            enabled: true,
4188            initial_delay: Duration::ZERO,
4189            retry_delay: Duration::from_millis(250),
4190            max_delay: Duration::from_secs(30),
4191            max_attempts: Some(3),
4192            dedupe_window: 4096,
4193        }
4194    }
4195}
4196
4197/// Historical log backfill requested when adding subscriber interests.
4198///
4199/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
4200/// pending-transaction interests are live-only. `AlloySubscriber` emits records
4201/// fetched through this policy as [`InputSource::Backfill`] before attempting
4202/// live stream initialization, and a drained backfill seeds the filter's
4203/// delivery anchor at its resolved upper bound (even when the window held no
4204/// logs), so the newly added filter gets the same reconnect/catch-up protection
4205/// an established one has.
4206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4207pub struct SubscriberBackfill {
4208    from_block: u64,
4209    to_block: Option<u64>,
4210}
4211
4212impl SubscriberBackfill {
4213    /// Backfill an inclusive block range.
4214    pub fn range(from_block: u64, to_block: u64) -> Self {
4215        Self {
4216            from_block,
4217            to_block: Some(to_block),
4218        }
4219    }
4220
4221    /// Backfill from `from_block` through the provider's latest block.
4222    pub fn from_block(from_block: u64) -> Self {
4223        Self {
4224            from_block,
4225            to_block: None,
4226        }
4227    }
4228
4229    /// First block included in the backfill.
4230    pub fn start_block(&self) -> u64 {
4231        self.from_block
4232    }
4233
4234    /// Last block included in the backfill, or `None` for provider latest.
4235    pub fn end_block(&self) -> Option<u64> {
4236        self.to_block
4237    }
4238}
4239
4240/// Opaque generation for one transaction-aware subscriber interest owner.
4241///
4242/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
4243/// reused, including after an aborted stage or a full interest replacement.
4244/// Lifecycle operations require the complete token so a delayed command for an
4245/// older registration cannot affect a replacement using the same [`HandlerId`].
4246#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
4247pub struct SubscriberOwnerEpoch {
4248    owner: HandlerId,
4249    sequence: u64,
4250}
4251
4252/// Delivery audience retained with a subscriber input record.
4253///
4254/// Canonical inputs are forwarded once to the runtime actor and may also name
4255/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
4256/// overlap records that must never be routed through existing canonical
4257/// handlers.
4258#[derive(Clone, Debug, PartialEq, Eq)]
4259#[non_exhaustive]
4260pub enum SubscriberInputScope {
4261    /// One canonical input plus any staged owners that matched at enqueue time.
4262    Canonical {
4263        /// Staged owner epochs that require a buffered copy.
4264        owners: Vec<SubscriberOwnerEpoch>,
4265    },
4266    /// Input delivered only to the listed staged owners.
4267    OwnerOnly {
4268        /// Exact staged owner epochs receiving the input.
4269        owners: Vec<SubscriberOwnerEpoch>,
4270    },
4271}
4272
4273impl SubscriberInputScope {
4274    /// Exact staged owner epochs attached to this input.
4275    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
4276        match self {
4277            Self::Canonical { owners } | Self::OwnerOnly { owners } => owners,
4278        }
4279    }
4280
4281    /// Whether this input must be forwarded once through canonical routing.
4282    pub const fn is_canonical(&self) -> bool {
4283        matches!(self, Self::Canonical { .. })
4284    }
4285}
4286
4287/// Reactive input together with its canonical/owner-scoped delivery audience.
4288#[derive(Clone, Debug)]
4289pub struct SubscriberInputRecord<N: Network = Ethereum> {
4290    record: ReactiveInputRecord<N>,
4291    scope: SubscriberInputScope,
4292}
4293
4294impl<N: Network> SubscriberInputRecord<N> {
4295    /// Borrow the reactive input record.
4296    pub const fn record(&self) -> &ReactiveInputRecord<N> {
4297        &self.record
4298    }
4299
4300    /// Delivery audience captured when the record was enqueued.
4301    pub const fn scope(&self) -> &SubscriberInputScope {
4302        &self.scope
4303    }
4304
4305    /// Consume the scoped value into its reactive input record.
4306    pub fn into_record(self) -> ReactiveInputRecord<N> {
4307        self.record
4308    }
4309}
4310
4311impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
4312    type Target = ReactiveInputRecord<N>;
4313
4314    fn deref(&self) -> &Self::Target {
4315        &self.record
4316    }
4317}
4318
4319/// Batch of subscriber inputs with enqueue-time owner provenance.
4320#[derive(Clone, Debug)]
4321pub struct SubscriberInputBatch<N: Network = Ethereum> {
4322    records: Vec<SubscriberInputRecord<N>>,
4323}
4324
4325/// Result of polling a scoped subscriber batch against one driver control
4326/// future.
4327#[derive(Debug)]
4328#[non_exhaustive]
4329pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
4330    /// The control future completed first; subscriber delivery remains intact.
4331    Control(C),
4332    /// Subscriber polling completed first.
4333    Batch(Option<SubscriberInputBatch<N>>),
4334}
4335
4336impl<N: Network> SubscriberInputBatch<N> {
4337    /// Borrow every scoped record in delivery order.
4338    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
4339        &self.records
4340    }
4341
4342    /// Consume the batch into its scoped records.
4343    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
4344        self.records
4345    }
4346
4347    fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
4348        ReactiveInputBatch::new(
4349            self.records
4350                .into_iter()
4351                .map(SubscriberInputRecord::into_record)
4352                .collect(),
4353        )
4354    }
4355}
4356
4357impl SubscriberOwnerEpoch {
4358    /// Logical subscriber owner represented by this epoch.
4359    pub const fn owner(&self) -> &HandlerId {
4360        &self.owner
4361    }
4362
4363    /// Monotonic subscriber-local epoch sequence.
4364    pub const fn sequence(&self) -> u64 {
4365        self.sequence
4366    }
4367}
4368
4369/// Catch-up policy applied when staging a transaction-aware interest owner.
4370#[derive(Clone, Debug, PartialEq, Eq)]
4371#[non_exhaustive]
4372pub enum SubscriberOwnerStart {
4373    /// Start with live delivery only.
4374    Live,
4375    /// Start strictly after an already-applied post-block baseline.
4376    ///
4377    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
4378    /// itself is never replayed. Transaction-aware callers explicitly call
4379    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
4380    /// owners never use the legacy lazy-backfill queue.
4381    PostBlock(BlockRef),
4382}
4383
4384/// Transaction state of one epoch-scoped subscriber owner.
4385#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
4386#[non_exhaustive]
4387pub enum SubscriberOwnerState {
4388    /// Desired interests and owner-scoped buffering are installed but canonical
4389    /// routing has not yet committed.
4390    Staged,
4391    /// Canonical runtime routing has committed for this owner.
4392    Active,
4393    /// Removal is prepared behind a delivery fence but remains reversible.
4394    Removing,
4395}
4396
4397/// Hash-certified catch-up position reached by one subscriber owner epoch.
4398///
4399/// Progress means every owner-only record through this point has been fetched
4400/// and queued inside the subscriber. It does not mean the downstream actor has
4401/// drained or committed those records; that requires a separate delivery fence.
4402#[derive(Clone, Debug, PartialEq, Eq)]
4403pub struct SubscriberOwnerProgress {
4404    owner: SubscriberOwnerEpoch,
4405    through: BlockRef,
4406}
4407
4408impl SubscriberOwnerProgress {
4409    /// Exact owner epoch whose catch-up was reconciled.
4410    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
4411        &self.owner
4412    }
4413
4414    /// Verified canonical block through which owner input was fetched.
4415    pub const fn through(&self) -> &BlockRef {
4416        &self.through
4417    }
4418}
4419
4420/// Error staging a transaction-aware subscriber owner.
4421#[derive(Debug, thiserror::Error)]
4422#[non_exhaustive]
4423pub enum SubscriberOwnerError {
4424    /// Subscriber configuration or interest validation failed.
4425    #[error(transparent)]
4426    Subscriber(#[from] SubscriberError),
4427    /// The logical owner already has desired interests installed.
4428    #[error("subscriber interest owner `{0}` is already registered")]
4429    AlreadyRegistered(HandlerId),
4430    /// A post-block baseline cannot be advanced to its first unapplied block.
4431    #[error("post-block subscriber baseline {0} has no following block")]
4432    PostBlockOverflow(u64),
4433    /// The monotonic subscriber owner epoch sequence was exhausted.
4434    #[error("subscriber owner epoch sequence exhausted")]
4435    EpochExhausted,
4436    /// The exact owner epoch is unknown or no longer staged.
4437    #[error("subscriber owner epoch is not staged")]
4438    NotStaged,
4439    /// Live-only staging has no historical baseline to reconcile.
4440    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
4441    MissingBaseline,
4442    /// Post-block reconciliation currently covers log interests only.
4443    #[error("post-block subscriber owners support log interests only")]
4444    UnsupportedPostBlockInterest,
4445    /// The target block was absent from the provider.
4446    #[error("subscriber reconcile target block {0} was not found")]
4447    BlockUnavailable(u64),
4448    /// The provider's canonical identity did not match the requested target.
4449    #[error(
4450        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
4451    )]
4452    BlockMismatch {
4453        /// Requested block number.
4454        expected_number: u64,
4455        /// Requested block hash.
4456        expected_hash: B256,
4457        /// Provider block number.
4458        actual_number: u64,
4459        /// Provider block hash.
4460        actual_hash: B256,
4461    },
4462    /// A reconcile target was older than the retained baseline/progress.
4463    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
4464    ProgressRegression {
4465        /// Retained baseline or progress block.
4466        current: u64,
4467        /// Rejected target block.
4468        target: u64,
4469    },
4470    /// A reconcile attempted to replace a retained block identity at the same
4471    /// height or cross an immediate parent that does not extend it.
4472    #[error(
4473        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
4474    )]
4475    ProgressConflict {
4476        /// Retained baseline or progress block number.
4477        number: u64,
4478        /// Retained baseline or progress block hash.
4479        current_hash: B256,
4480        /// Conflicting target hash or immediate parent hash.
4481        target_hash: B256,
4482    },
4483    /// A provider returned a malformed or out-of-range catch-up log.
4484    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
4485    InvalidBackfillLog(&'static str),
4486}
4487
4488/// Extension trait for subscribers that can add and remove handler-owned
4489/// interests incrementally.
4490///
4491/// [`EventSubscriber::register_interests`] remains the full-replacement setup
4492/// API. Implement this trait when a subscriber can preserve unrelated live
4493/// sources and delivery state while one handler's interests are added or
4494/// removed. Implementations should make owner *replacement* continuity-safe:
4495/// updating an owner's interests must not silently discard delivery progress
4496/// the previous interests had already established (the in-crate
4497/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
4498/// changed filter shapes and automatically backfills the gap).
4499pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
4500    /// Add or replace the interests owned by `owner`.
4501    fn add_interest_owner(
4502        &mut self,
4503        owner: HandlerId,
4504        interests: &[ReactiveInterest<N>],
4505    ) -> Result<(), SubscriberError>;
4506
4507    /// Add or replace owner interests and schedule log backfill for that owner.
4508    fn add_interest_owner_with_backfill(
4509        &mut self,
4510        owner: HandlerId,
4511        interests: &[ReactiveInterest<N>],
4512        backfill: SubscriberBackfill,
4513    ) -> Result<(), SubscriberError>;
4514
4515    /// Remove one owner's interests, preserving unrelated interests.
4516    fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>>;
4517
4518    /// Borrow the interests currently owned by `owner`.
4519    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
4520}
4521
4522/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
4523/// subscribe-ingest lifecycle.
4524///
4525/// The engine treats the runtime registry as the single source of truth for
4526/// handler lifecycle: [`register_handler`](Self::register_handler) and
4527/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
4528/// subscriber interests as one operation, keyed by the handler's stable
4529/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
4530/// has journaled a canonical block, a newly registered handler is backfilled
4531/// from that block, so a pool discovered in block *N* (say via a factory
4532/// `PoolCreated` event) misses none of its own logs from *N* onward even though
4533/// its live subscription starts later. Overlap between backfill and live
4534/// delivery is absorbed by subscriber and runtime dedup.
4535///
4536/// Registration methods by intent:
4537///
4538/// | Method | Backfill |
4539/// |---|---|
4540/// | [`register_handler`](Self::register_handler) | from the runtime's last canonical block (live-only on a fresh runtime) |
4541/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | explicit range or anchor (deep history) |
4542/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
4543///
4544/// Unregistering a handler stops future subscription routing and runtime
4545/// decode for that handler; it deliberately does not evict [`EvmCache`] state
4546/// or undo runtime side effects. See
4547/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
4548/// recipe.
4549///
4550/// The runtime and subscriber stay independently accessible through
4551/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
4552/// for advanced use. One caution: avoid calling
4553/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
4554/// an engine-managed subscriber — implementations may clear owner-scoped
4555/// bookkeeping, after which per-handler unregistration no longer releases the
4556/// handler's transport subscriptions. To bootstrap the subscriber from a
4557/// runtime that already has handlers, use
4558/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
4559/// one owner per handler instead of one unowned blob.
4560pub struct ReactiveEngine<S, N: Network = Ethereum> {
4561    runtime: ReactiveRuntime<N>,
4562    subscriber: S,
4563}
4564
4565impl<S, N> ReactiveEngine<S, N>
4566where
4567    N: Network,
4568    S: EventSubscriber<N>,
4569{
4570    /// Bind a runtime and subscriber.
4571    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
4572        Self {
4573            runtime,
4574            subscriber,
4575        }
4576    }
4577
4578    /// Split the engine into its runtime and subscriber parts.
4579    pub fn into_parts(self) -> (ReactiveRuntime<N>, S) {
4580        (self.runtime, self.subscriber)
4581    }
4582
4583    /// Borrow the runtime.
4584    pub fn runtime(&self) -> &ReactiveRuntime<N> {
4585        &self.runtime
4586    }
4587
4588    /// Mutably borrow the runtime.
4589    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
4590        &mut self.runtime
4591    }
4592
4593    /// Borrow the subscriber.
4594    pub fn subscriber(&self) -> &S {
4595        &self.subscriber
4596    }
4597
4598    /// Mutably borrow the subscriber.
4599    pub fn subscriber_mut(&mut self) -> &mut S {
4600        &mut self.subscriber
4601    }
4602
4603    /// Poll the subscriber for the next batch.
4604    pub fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
4605        self.subscriber.next_batch()
4606    }
4607
4608    /// Ingest one already-polled batch through the runtime (direct effects
4609    /// only; surfaced resync requests are reported, not executed).
4610    pub fn ingest_batch(
4611        &mut self,
4612        cache: &mut EvmCache,
4613        batch: ReactiveInputBatch<N>,
4614    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4615        self.runtime.ingest_batch(cache, batch)
4616    }
4617
4618    /// Ingest one already-polled batch and execute the storage/account resyncs
4619    /// it surfaces, exactly like
4620    /// [`ReactiveRuntime::ingest_batch_with_resync`].
4621    pub fn ingest_batch_with_resync(
4622        &mut self,
4623        cache: &mut EvmCache,
4624        batch: ReactiveInputBatch<N>,
4625    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4626        self.runtime.ingest_batch_with_resync(cache, batch)
4627    }
4628
4629    /// Poll the subscriber once and ingest the returned batch when present
4630    /// (direct effects only).
4631    pub async fn next_ingest(
4632        &mut self,
4633        cache: &mut EvmCache,
4634    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
4635        let Some(batch) = self.subscriber.next_batch().await? else {
4636            return Ok(None);
4637        };
4638        Ok(Some(self.runtime.ingest_batch(cache, batch)?))
4639    }
4640
4641    /// Poll the subscriber once and ingest the returned batch with resync
4642    /// execution — the loop shape for consumers that rely on coverage-gap
4643    /// repair (root-gate resyncs, handler-requested re-reads).
4644    pub async fn next_ingest_with_resync(
4645        &mut self,
4646        cache: &mut EvmCache,
4647    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
4648        let Some(batch) = self.subscriber.next_batch().await? else {
4649            return Ok(None);
4650        };
4651        Ok(Some(self.runtime.ingest_batch_with_resync(cache, batch)?))
4652    }
4653}
4654
4655impl<S, N> ReactiveEngine<S, N>
4656where
4657    N: Network,
4658    S: InterestOwnerSubscriber<N>,
4659{
4660    /// Register a handler with both the runtime and subscriber, backfilling its
4661    /// log interests from the runtime's last canonical block.
4662    ///
4663    /// This is the continuity-safe default for mid-lifecycle registration: the
4664    /// runtime already knows how far it has processed the chain, so the new
4665    /// handler's logs are fetched from that block forward and no discovery gap
4666    /// opens between "we decided to track this pool" and "its live subscription
4667    /// started". On a runtime that has not journaled any canonical block yet
4668    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
4669    /// pre-ingestion bootstrap. Use
4670    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
4671    /// for deeper history or
4672    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
4673    /// out of backfill entirely.
4674    ///
4675    /// If subscriber registration fails, the runtime registration is rolled back
4676    /// before the error is returned.
4677    pub fn register_handler(
4678        &mut self,
4679        handler: Arc<dyn ReactiveHandler<N>>,
4680    ) -> Result<(), ReactiveEngineRegisterError> {
4681        let backfill = self
4682            .runtime
4683            .last_canonical_block()
4684            .map(|block| SubscriberBackfill::from_block(block.number));
4685        self.register_handler_inner(handler, backfill)
4686    }
4687
4688    /// Register a handler and request an explicit owner-scoped log backfill for
4689    /// its interests (deep history / custom anchors).
4690    ///
4691    /// If subscriber registration fails, the runtime registration is rolled back
4692    /// before the error is returned.
4693    pub fn register_handler_with_backfill(
4694        &mut self,
4695        handler: Arc<dyn ReactiveHandler<N>>,
4696        backfill: SubscriberBackfill,
4697    ) -> Result<(), ReactiveEngineRegisterError> {
4698        self.register_handler_inner(handler, Some(backfill))
4699    }
4700
4701    /// Register a handler without any log backfill — only logs delivered after
4702    /// its live subscription starts are routed to it.
4703    ///
4704    /// If subscriber registration fails, the runtime registration is rolled back
4705    /// before the error is returned.
4706    pub fn register_handler_live_only(
4707        &mut self,
4708        handler: Arc<dyn ReactiveHandler<N>>,
4709    ) -> Result<(), ReactiveEngineRegisterError> {
4710        self.register_handler_inner(handler, None)
4711    }
4712
4713    fn register_handler_inner(
4714        &mut self,
4715        handler: Arc<dyn ReactiveHandler<N>>,
4716        backfill: Option<SubscriberBackfill>,
4717    ) -> Result<(), ReactiveEngineRegisterError> {
4718        let id = handler.id();
4719        self.runtime.register_handler(handler)?;
4720        let interests = self
4721            .runtime
4722            .handler_interests(&id)
4723            .expect("handler was just registered")
4724            .to_vec();
4725
4726        let subscribed = match backfill {
4727            Some(backfill) => {
4728                self.subscriber
4729                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
4730            }
4731            None => self.subscriber.add_interest_owner(id.clone(), &interests),
4732        };
4733        if let Err(error) = subscribed {
4734            self.runtime.unregister_handler(&id);
4735            return Err(error.into());
4736        }
4737
4738        Ok(())
4739    }
4740
4741    /// Register every handler currently in the runtime registry as a subscriber
4742    /// interest owner.
4743    ///
4744    /// This is the bootstrap path for an engine built around a pre-populated
4745    /// runtime: each handler becomes its own owner (upsert semantics, so
4746    /// rerunning is safe and already-registered owners are refreshed in place).
4747    /// No backfill is requested — bootstrap happens before ingestion starts, so
4748    /// there is no processed position to be continuous with; use
4749    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
4750    /// for handlers that need history. Owners are not removed by this call: use
4751    /// [`unregister_handler`](Self::unregister_handler) for lifecycle removal
4752    /// rather than mutating the runtime registry directly.
4753    ///
4754    /// On error, owners already synced stay registered (upserts are
4755    /// independent); the call can simply be retried.
4756    pub fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
4757        for id in self.runtime.handler_ids() {
4758            let interests = self
4759                .runtime
4760                .handler_interests(&id)
4761                .map(<[ReactiveInterest<N>]>::to_vec)
4762                .unwrap_or_default();
4763            self.subscriber.add_interest_owner(id, &interests)?;
4764        }
4765        Ok(())
4766    }
4767
4768    /// Unregister a handler from both the subscriber and runtime.
4769    ///
4770    /// Subscriber interests are removed first so no new live records are routed
4771    /// to a handler after it has left the runtime registry. Returns the removed
4772    /// handler when the id was registered.
4773    ///
4774    /// This is the routing/transport half of dropping an adapter. State the
4775    /// handler accumulated is deliberately left in place; the complete teardown
4776    /// for a pool or adapter that will not return is:
4777    ///
4778    /// ```text
4779    /// engine.unregister_handler(&id);
4780    /// for request_id in handler_request_ids {
4781    ///     // Drop only this handler generation's queued repair work.
4782    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
4783    /// }
4784    /// for address in exclusively_owned_addresses {
4785    ///     // Shared accounts require caller-side owner reference counting.
4786    ///     engine.runtime_mut().untrack_account(address);
4787    /// }
4788    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
4789    /// ```
4790    ///
4791    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
4792    /// runtime-global and are never touched by handler removal.
4793    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4794        self.subscriber.remove_interest_owner(id);
4795        self.runtime.unregister_handler(id)
4796    }
4797}
4798
4799/// Alloy-backed event subscriber.
4800///
4801/// The default transport slice drives Alloy pubsub subscriptions for logs,
4802/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
4803/// transport remains available behind the opt-in `reactive-polling` feature.
4804/// Pubsub streams reconnect automatically after termination, and log
4805/// subscriptions are backfilled from the last seen block. Owner-scoped log
4806/// additions can request backfill from an explicit block anchor. Full pending
4807/// transaction hydration and full block bodies remain explicit follow-up work.
4808/// With no registered interests, [`EventSubscriber::next_batch`] returns
4809/// `Ok(None)`.
4810pub struct AlloySubscriber<P, N: Network = Ethereum> {
4811    provider: P,
4812    mode: SubscriberMode,
4813    config: SubscriberConfig,
4814    base_interests: Vec<ReactiveInterest<N>>,
4815    owned_interests: Vec<OwnedSubscriberInterests<N>>,
4816    next_owner_epoch: u64,
4817    interests: Vec<ReactiveInterest<N>>,
4818    /// Stable source id per distinct provider-facing log filter. Ids key
4819    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
4820    /// their anchors pruned) when no planned stream references the filter, so
4821    /// long-lived owner churn cannot grow this map unboundedly.
4822    log_source_ids: HashMap<Filter, usize>,
4823    next_log_source_id: usize,
4824    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
4825    /// Set when interest bookkeeping changed since the last successful stream
4826    /// reconcile, so steady-state polling skips the desired-vs-live diff.
4827    sources_dirty: bool,
4828    /// Conservative generation of desired/live stream topology. Successful
4829    /// owner progress is activatable only against the same clean revision.
4830    stream_revision: u64,
4831    state: AlloySubscriberState<N>,
4832    pending_records: VecDeque<SubscriberInputRecord<N>>,
4833    /// Owner copies of live records consumed during an in-flight reconcile.
4834    /// These remain hidden from subscriber output until the owning reconcile
4835    /// commits and survive cancellation so subscribe-first adoption cannot
4836    /// lose an event at an await boundary.
4837    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
4838    last_seen_log_blocks: HashMap<usize, u64>,
4839    recent_input_refs: VecDeque<InputRef>,
4840    recent_input_ref_set: HashSet<InputRef>,
4841    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
4842    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
4843    _network: PhantomData<N>,
4844}
4845
4846struct OwnedSubscriberInterests<N: Network = Ethereum> {
4847    owner: HandlerId,
4848    interests: Vec<ReactiveInterest<N>>,
4849    epoch: Option<SubscriberOwnerEpoch>,
4850    state: SubscriberOwnerState,
4851    baseline: Option<BlockRef>,
4852    progress: Option<SubscriberOwnerProgress>,
4853    progress_stream_revision: Option<u64>,
4854}
4855
4856#[derive(Clone)]
4857struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
4858    epoch: SubscriberOwnerEpoch,
4859    interests: Vec<ReactiveInterest<N>>,
4860    retained: BlockRef,
4861    from_block: u64,
4862}
4863
4864struct SubscriberOwnerCatchup {
4865    logs: Vec<Log>,
4866    certified: BlockRef,
4867}
4868
4869struct SubscriberOwnerReconcileFilter {
4870    filter: Filter,
4871    from_block: u64,
4872}
4873
4874struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
4875    record: ReactiveInputRecord<N>,
4876    owners: Vec<SubscriberOwnerEpoch>,
4877}
4878
4879const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
4880
4881struct QueuedSubscriberBackfill {
4882    owner: HandlerId,
4883    epoch: Option<SubscriberOwnerEpoch>,
4884    filter: Filter,
4885    backfill: SubscriberBackfill,
4886}
4887
4888/// Best-effort installation of rustls' `ring` crypto provider as the process
4889/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
4890/// "no process-level CryptoProvider available". Runs at most once and ignores the
4891/// error if a default provider is already installed (the host app may have set
4892/// its own).
4893#[cfg(feature = "reactive-ws")]
4894fn ensure_ring_crypto_provider() {
4895    use std::sync::Once;
4896    static INSTALL: Once = Once::new();
4897    INSTALL.call_once(|| {
4898        let _ = rustls::crypto::ring::default_provider().install_default();
4899    });
4900}
4901
4902impl<P, N: Network> AlloySubscriber<P, N> {
4903    /// Create a new Alloy subscriber.
4904    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
4905        #[cfg(feature = "reactive-ws")]
4906        ensure_ring_crypto_provider();
4907        Self {
4908            provider,
4909            mode,
4910            config,
4911            base_interests: Vec::new(),
4912            owned_interests: Vec::new(),
4913            next_owner_epoch: 0,
4914            interests: Vec::new(),
4915            log_source_ids: HashMap::new(),
4916            next_log_source_id: 0,
4917            pending_backfills: VecDeque::new(),
4918            sources_dirty: true,
4919            stream_revision: 0,
4920            state: AlloySubscriberState::Uninitialized,
4921            pending_records: VecDeque::new(),
4922            pending_reconcile_owner_records: VecDeque::new(),
4923            last_seen_log_blocks: HashMap::new(),
4924            recent_input_refs: VecDeque::new(),
4925            recent_input_ref_set: HashSet::new(),
4926            recent_owner_input_refs: HashMap::new(),
4927            recent_owner_input_ref_sets: HashMap::new(),
4928            _network: PhantomData,
4929        }
4930    }
4931
4932    /// Borrow the provider.
4933    pub fn provider(&self) -> &P {
4934        &self.provider
4935    }
4936
4937    /// Subscriber mode.
4938    pub fn mode(&self) -> SubscriberMode {
4939        self.mode
4940    }
4941
4942    /// Subscriber config.
4943    pub fn config(&self) -> &SubscriberConfig {
4944        &self.config
4945    }
4946
4947    /// Registered interests across base and owner-scoped registrations.
4948    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
4949        &self.interests
4950    }
4951
4952    /// Stage a fresh, epoch-scoped interest owner without making its inputs
4953    /// canonically routable yet.
4954    ///
4955    /// The returned token is required by every later lifecycle operation. A
4956    /// staged owner participates in provider subscription planning immediately,
4957    /// while its matching input remains owner-scoped until
4958    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
4959    /// Post-block owners require hash-certified
4960    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
4961    /// the current clean stream revision before activation.
4962    pub fn stage_interest_owner(
4963        &mut self,
4964        owner: HandlerId,
4965        interests: &[ReactiveInterest<N>],
4966        start: SubscriberOwnerStart,
4967    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
4968        validate_subscriber_config(&self.config)?;
4969        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
4970            && interests
4971                .iter()
4972                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
4973        {
4974            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
4975        }
4976        if self
4977            .owned_interests
4978            .iter()
4979            .any(|entry| entry.owner == owner)
4980        {
4981            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
4982        }
4983
4984        let mut next_owned = self.clone_owned_interests();
4985        next_owned.push(OwnedSubscriberInterests {
4986            owner: owner.clone(),
4987            interests: interests.to_vec(),
4988            epoch: None,
4989            state: SubscriberOwnerState::Staged,
4990            baseline: None,
4991            progress: None,
4992            progress_stream_revision: None,
4993        });
4994        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
4995        validate_supported_interests(self.mode, &self.config, &next_registered)?;
4996
4997        let baseline = match start {
4998            SubscriberOwnerStart::Live => None,
4999            SubscriberOwnerStart::PostBlock(block) => {
5000                block
5001                    .number
5002                    .checked_add(1)
5003                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
5004                Some(block)
5005            }
5006        };
5007        let sequence = self
5008            .next_owner_epoch
5009            .checked_add(1)
5010            .ok_or(SubscriberOwnerError::EpochExhausted)?;
5011        let epoch = SubscriberOwnerEpoch {
5012            owner: owner.clone(),
5013            sequence,
5014        };
5015
5016        self.next_owner_epoch = sequence;
5017        let entry = next_owned
5018            .last_mut()
5019            .expect("staged owner was appended during preflight");
5020        entry.epoch = Some(epoch.clone());
5021        entry.baseline = baseline;
5022        self.owned_interests = next_owned;
5023        self.interests = next_registered;
5024        self.sources_dirty = true;
5025
5026        Ok(epoch)
5027    }
5028
5029    /// Stage replacement interests for one currently active logical owner.
5030    ///
5031    /// The active epoch remains canonical while the replacement reconciles.
5032    /// Commit both epochs atomically with
5033    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
5034    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
5035    pub fn stage_interest_owner_replacement(
5036        &mut self,
5037        owner: HandlerId,
5038        interests: &[ReactiveInterest<N>],
5039        start: SubscriberOwnerStart,
5040    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
5041        validate_subscriber_config(&self.config)?;
5042        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
5043            && interests
5044                .iter()
5045                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
5046        {
5047            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
5048        }
5049        let active_count = self
5050            .owned_interests
5051            .iter()
5052            .filter(|entry| entry.owner == owner && entry.state == SubscriberOwnerState::Active)
5053            .count();
5054        if active_count != 1
5055            || self
5056                .owned_interests
5057                .iter()
5058                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
5059        {
5060            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
5061        }
5062
5063        let mut next_owned = self.clone_owned_interests();
5064        next_owned.push(OwnedSubscriberInterests {
5065            owner: owner.clone(),
5066            interests: interests.to_vec(),
5067            epoch: None,
5068            state: SubscriberOwnerState::Staged,
5069            baseline: None,
5070            progress: None,
5071            progress_stream_revision: None,
5072        });
5073        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
5074        validate_supported_interests(self.mode, &self.config, &next_registered)?;
5075
5076        let baseline = match start {
5077            SubscriberOwnerStart::Live => None,
5078            SubscriberOwnerStart::PostBlock(block) => {
5079                block
5080                    .number
5081                    .checked_add(1)
5082                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
5083                Some(block)
5084            }
5085        };
5086        let sequence = self
5087            .next_owner_epoch
5088            .checked_add(1)
5089            .ok_or(SubscriberOwnerError::EpochExhausted)?;
5090        let epoch = SubscriberOwnerEpoch {
5091            owner: owner.clone(),
5092            sequence,
5093        };
5094
5095        self.next_owner_epoch = sequence;
5096        let entry = next_owned
5097            .last_mut()
5098            .expect("staged replacement owner was appended during preflight");
5099        entry.epoch = Some(epoch.clone());
5100        entry.baseline = baseline;
5101        self.owned_interests = next_owned;
5102        self.interests = next_registered;
5103        self.sources_dirty = true;
5104        Ok(epoch)
5105    }
5106
5107    /// Current transaction state for an exact owner epoch.
5108    pub fn interest_owner_state(
5109        &self,
5110        epoch: &SubscriberOwnerEpoch,
5111    ) -> Option<SubscriberOwnerState> {
5112        self.owned_interests
5113            .iter()
5114            .find(|entry| entry.epoch.as_ref() == Some(epoch))
5115            .map(|entry| entry.state)
5116    }
5117
5118    /// Latest hash-certified reconcile progress for an exact owner epoch.
5119    pub fn interest_owner_progress(
5120        &self,
5121        epoch: &SubscriberOwnerEpoch,
5122    ) -> Option<&SubscriberOwnerProgress> {
5123        self.owned_interests
5124            .iter()
5125            .find(|entry| entry.epoch.as_ref() == Some(epoch))
5126            .and_then(|entry| entry.progress.as_ref())
5127    }
5128
5129    /// Make a staged owner canonical after its actor-side installation commits.
5130    ///
5131    /// Returns `false` for stale tokens and owners not currently staged.
5132    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
5133        let stream_revision = self.stream_revision;
5134        let sources_dirty = self.sources_dirty;
5135        let Some(entry) = self
5136            .owned_interests
5137            .iter_mut()
5138            .find(|entry| entry.epoch.as_ref() == Some(epoch))
5139        else {
5140            return false;
5141        };
5142        if entry.state != SubscriberOwnerState::Staged
5143            || (entry.baseline.is_some()
5144                && (entry.progress.is_none()
5145                    || entry.progress_stream_revision != Some(stream_revision)
5146                    || sources_dirty))
5147        {
5148            return false;
5149        }
5150        entry.state = SubscriberOwnerState::Active;
5151        true
5152    }
5153
5154    /// Atomically replace one active owner epoch with one reconciled staged epoch.
5155    pub fn commit_interest_owner_replacement(
5156        &mut self,
5157        active: &SubscriberOwnerEpoch,
5158        replacement: &SubscriberOwnerEpoch,
5159    ) -> bool {
5160        let Some(active_index) = self
5161            .owned_interests
5162            .iter()
5163            .position(|entry| entry.epoch.as_ref() == Some(active))
5164        else {
5165            return false;
5166        };
5167        let Some(replacement_index) = self
5168            .owned_interests
5169            .iter()
5170            .position(|entry| entry.epoch.as_ref() == Some(replacement))
5171        else {
5172            return false;
5173        };
5174        if active_index == replacement_index
5175            || active.owner() != replacement.owner()
5176            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
5177            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
5178            || (self.owned_interests[replacement_index].baseline.is_some()
5179                && (self.owned_interests[replacement_index].progress.is_none()
5180                    || self.owned_interests[replacement_index].progress_stream_revision
5181                        != Some(self.stream_revision)
5182                    || self.sources_dirty))
5183        {
5184            return false;
5185        }
5186
5187        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
5188        self.owned_interests.remove(active_index);
5189        self.purge_owner_epoch(active);
5190        self.rebuild_registered_interests();
5191        self.retire_unreferenced_filters();
5192        self.sources_dirty = true;
5193        true
5194    }
5195
5196    /// Prepare an exact active owner for removal without changing desired
5197    /// interests, streams, anchors, or queued canonical input.
5198    ///
5199    /// The caller establishes its delivery fence after this transition. Use
5200    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
5201    /// on actor-side failure, or
5202    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
5203    /// once canonical routing has been removed.
5204    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
5205        let Some(entry) = self
5206            .owned_interests
5207            .iter_mut()
5208            .find(|entry| entry.epoch.as_ref() == Some(epoch))
5209        else {
5210            return false;
5211        };
5212        if entry.state != SubscriberOwnerState::Active {
5213            return false;
5214        }
5215        entry.state = SubscriberOwnerState::Removing;
5216        true
5217    }
5218
5219    /// Finalize a previously prepared exact owner removal.
5220    ///
5221    /// Returns the removed interests, or `None` for stale tokens and owners not
5222    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
5223    /// is therefore idempotent.
5224    pub fn finalize_interest_owner_removal(
5225        &mut self,
5226        epoch: &SubscriberOwnerEpoch,
5227    ) -> Option<Vec<ReactiveInterest<N>>> {
5228        let index = self.owned_interests.iter().position(|entry| {
5229            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
5230        })?;
5231        let removed = self.owned_interests.remove(index).interests;
5232        self.purge_owner_epoch(epoch);
5233        self.rebuild_registered_interests();
5234        self.retire_unreferenced_filters();
5235        self.sources_dirty = true;
5236        Some(removed)
5237    }
5238
5239    /// Abort an epoch-scoped owner lifecycle operation.
5240    ///
5241    /// A staged owner is removed completely. A prepared removal is restored to
5242    /// active. Active and unknown epochs are unchanged. Repeating the same
5243    /// abort is therefore safe and returns `false` after the first effect.
5244    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
5245        let Some(index) = self
5246            .owned_interests
5247            .iter()
5248            .position(|entry| entry.epoch.as_ref() == Some(epoch))
5249        else {
5250            return false;
5251        };
5252        match self.owned_interests[index].state {
5253            SubscriberOwnerState::Staged => {
5254                self.owned_interests.remove(index);
5255                self.purge_owner_epoch(epoch);
5256                self.rebuild_registered_interests();
5257                self.retire_unreferenced_filters();
5258                self.sources_dirty = true;
5259                true
5260            }
5261            SubscriberOwnerState::Removing => {
5262                self.owned_interests[index].state = SubscriberOwnerState::Active;
5263                true
5264            }
5265            SubscriberOwnerState::Active => false,
5266        }
5267    }
5268
5269    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
5270        self.pending_backfills
5271            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
5272        self.pending_records
5273            .retain_mut(|pending| match &mut pending.scope {
5274                SubscriberInputScope::Canonical { owners } => {
5275                    owners.retain(|owner| owner != epoch);
5276                    true
5277                }
5278                SubscriberInputScope::OwnerOnly { owners } => {
5279                    owners.retain(|owner| owner != epoch);
5280                    !owners.is_empty()
5281                }
5282            });
5283        self.pending_reconcile_owner_records.retain_mut(|pending| {
5284            pending.owners.retain(|owner| owner != epoch);
5285            !pending.owners.is_empty()
5286        });
5287        self.recent_owner_input_refs.remove(epoch);
5288        self.recent_owner_input_ref_sets.remove(epoch);
5289    }
5290
5291    /// Add or replace the interests owned by `owner`.
5292    ///
5293    /// This preserves unrelated owners, queued/pending records, recent dedupe
5294    /// state, and last-seen log anchors. The live transport is reconciled on the
5295    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
5296    /// be subscribed without rebuilding the whole subscriber object.
5297    ///
5298    /// Replacing an existing owner is continuity-safe: filters the owner
5299    /// already had keep their delivery anchors, and any changed or new filter
5300    /// shape is automatically backfilled from the owner's oldest prior anchor —
5301    /// growing a pool set on an established owner does not open a delivery gap
5302    /// for what the old subscription had already covered. A brand-new owner has
5303    /// no anchor to inherit; pass an explicit
5304    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
5305    /// anchor (or register through [`ReactiveEngine::register_handler`], which
5306    /// anchors to the runtime's last canonical block).
5307    pub fn add_interest_owner(
5308        &mut self,
5309        owner: HandlerId,
5310        interests: &[ReactiveInterest<N>],
5311    ) -> Result<(), SubscriberError> {
5312        self.set_interest_owner(owner, interests, None)
5313    }
5314
5315    /// Add or replace owner interests and schedule log backfill for that owner.
5316    ///
5317    /// Backfill is queued only for log interests; block and pending transaction
5318    /// interests are live-only. Queued records can be delivered immediately;
5319    /// the subsequent provider stream is then caught up from the seeded
5320    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
5321    /// is closed end to end as long
5322    /// as `backfill` starts at (or before) the block the interest was
5323    /// discovered in. Continuity backfill for a replaced owner (see
5324    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
5325    /// unless this explicit backfill is open-ended and already starts at or
5326    /// below the owner's prior anchor.
5327    pub fn add_interest_owner_with_backfill(
5328        &mut self,
5329        owner: HandlerId,
5330        interests: &[ReactiveInterest<N>],
5331        backfill: SubscriberBackfill,
5332    ) -> Result<(), SubscriberError> {
5333        self.set_interest_owner(owner, interests, Some(backfill))
5334    }
5335
5336    /// Remove one owner's interests, preserving unrelated owner/base interests.
5337    ///
5338    /// The owner's queued backfills are dropped, and source-id/anchor
5339    /// bookkeeping for filters no other owner references is retired. Live
5340    /// streams for retired filters are torn down on the next
5341    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
5342    /// unsubscribes provider-side); events already in flight from them stop
5343    /// matching the merged interest set and are discarded.
5344    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
5345        let index = self
5346            .owned_interests
5347            .iter()
5348            .position(|entry| &entry.owner == owner)?;
5349        let removed = self.owned_interests.remove(index);
5350        if let Some(epoch) = &removed.epoch {
5351            self.purge_owner_epoch(epoch);
5352        } else {
5353            self.pending_backfills
5354                .retain(|backfill| &backfill.owner != owner);
5355        }
5356        self.rebuild_registered_interests();
5357        self.retire_unreferenced_filters();
5358        self.sources_dirty = true;
5359        Some(removed.interests)
5360    }
5361
5362    /// Borrow the interests currently owned by `owner`.
5363    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
5364        self.owned_interests
5365            .iter()
5366            .find(|entry| &entry.owner == owner)
5367            .map(|entry| entry.interests.as_slice())
5368    }
5369
5370    fn set_interest_owner(
5371        &mut self,
5372        owner: HandlerId,
5373        interests: &[ReactiveInterest<N>],
5374        backfill: Option<SubscriberBackfill>,
5375    ) -> Result<(), SubscriberError> {
5376        validate_subscriber_config(&self.config)?;
5377
5378        let mut next_owned = self.clone_owned_interests();
5379        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
5380            Some(entry) => {
5381                entry.interests = interests.to_vec();
5382                entry.state = SubscriberOwnerState::Active;
5383                entry.baseline = None;
5384                entry.progress = None;
5385                entry.progress_stream_revision = None;
5386                entry.epoch.take()
5387            }
5388            None => {
5389                next_owned.push(OwnedSubscriberInterests {
5390                    owner: owner.clone(),
5391                    interests: interests.to_vec(),
5392                    epoch: None,
5393                    state: SubscriberOwnerState::Active,
5394                    baseline: None,
5395                    progress: None,
5396                    progress_stream_revision: None,
5397                });
5398                None
5399            }
5400        };
5401        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
5402        validate_supported_interests(self.mode, &self.config, &next_registered)?;
5403
5404        // Continuity capture, before the mutation lands: the owner's previous
5405        // filter shapes and the oldest delivery anchor among them. A changed
5406        // filter gets a fresh source id with no anchor, so without this
5407        // hand-off, replacing an owner's interests (the normal way to grow a
5408        // pool set) would silently discard the delivery watermark and open a
5409        // gap until some later explicit backfill.
5410        let previous_filters: Vec<Filter> = self
5411            .owner_interests(&owner)
5412            .map(log_filters)
5413            .unwrap_or_default();
5414        let continuity_anchor: Option<u64> = previous_filters
5415            .iter()
5416            .filter_map(|filter| self.log_anchor(filter))
5417            .min();
5418
5419        self.owned_interests = next_owned;
5420        self.interests = next_registered;
5421        if let Some(epoch) = replaced_epoch {
5422            self.purge_owner_epoch(&epoch);
5423        }
5424        self.retire_unreferenced_filters();
5425        self.sources_dirty = true;
5426
5427        // Re-queue this owner's backfills from scratch: previously queued
5428        // entries may reference filter shapes that no longer exist.
5429        self.pending_backfills
5430            .retain(|queued| queued.owner != owner);
5431        for filter in log_filters(interests) {
5432            if let Some(backfill) = backfill {
5433                self.pending_backfills.push_back(QueuedSubscriberBackfill {
5434                    owner: owner.clone(),
5435                    epoch: None,
5436                    filter: filter.clone(),
5437                    backfill,
5438                });
5439            }
5440
5441            // Continuity backfill for changed/new shapes only: an unchanged
5442            // filter kept its anchor and its live stream, and an open-ended
5443            // explicit backfill starting at or below the anchor already covers
5444            // the window.
5445            let unchanged = previous_filters.contains(&filter);
5446            let explicit_covers = backfill.is_some_and(|explicit| {
5447                explicit.end_block().is_none()
5448                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
5449            });
5450            if let Some(anchor) = continuity_anchor
5451                && !unchanged
5452                && !explicit_covers
5453            {
5454                self.pending_backfills.push_back(QueuedSubscriberBackfill {
5455                    owner: owner.clone(),
5456                    epoch: None,
5457                    filter,
5458                    backfill: SubscriberBackfill::from_block(anchor),
5459                });
5460            }
5461        }
5462        Ok(())
5463    }
5464
5465    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
5466        self.owned_interests
5467            .iter()
5468            .map(|entry| OwnedSubscriberInterests {
5469                owner: entry.owner.clone(),
5470                interests: entry.interests.clone(),
5471                epoch: entry.epoch.clone(),
5472                state: entry.state,
5473                baseline: entry.baseline.clone(),
5474                progress: entry.progress.clone(),
5475                progress_stream_revision: entry.progress_stream_revision,
5476            })
5477            .collect()
5478    }
5479
5480    fn rebuild_registered_interests(&mut self) {
5481        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
5482    }
5483
5484    /// Delivery anchor (last block known fully delivered) for `filter`, if the
5485    /// filter has a source id and has seen delivery.
5486    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
5487        if let Some(anchor) = self
5488            .log_source_ids
5489            .get(filter)
5490            .and_then(|id| self.last_seen_log_blocks.get(id))
5491        {
5492            return Some(*anchor);
5493        }
5494
5495        // Logical owner filters may be represented by a broader provider
5496        // stream after fan-in. Its oldest live watermark is a conservative
5497        // continuity anchor: it can cause extra backfill, never a missed log.
5498        self.log_source_ids
5499            .values()
5500            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
5501            .min()
5502    }
5503
5504    /// Every logical log filter across base and owner interests, merged within
5505    /// each origin and deduplicated across origins. These shapes remain the
5506    /// exact routing and owner-continuity boundary; provider subscriptions may
5507    /// fan several of them into one broader filter.
5508    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
5509    // `mutable_key_type` lint is a known false positive for it.
5510    #[allow(clippy::mutable_key_type)]
5511    fn logical_log_filters(&self) -> Vec<Filter> {
5512        let mut filters = log_filters(&self.base_interests);
5513        for entry in &self.owned_interests {
5514            filters.extend(log_filters(&entry.interests));
5515        }
5516        let mut seen = HashSet::new();
5517        filters.retain(|filter| seen.insert(filter.clone()));
5518        filters
5519    }
5520
5521    /// Provider-facing log filters. Compatible logical filters fan into a
5522    /// small number of address/topic supersets, then split only when the
5523    /// configured address ceiling requires it. Exact matching remains local in
5524    /// `enqueue_event`, so this reduces subscriptions without broadening owner
5525    /// delivery.
5526    fn log_stream_filters(&self) -> Vec<Filter> {
5527        let mut merged = Vec::new();
5528        for filter in self.logical_log_filters() {
5529            merge_log_subscription_filter(&mut merged, &filter);
5530        }
5531
5532        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
5533        let mut planned = Vec::new();
5534        for filter in merged {
5535            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
5536            if addresses.len() <= max_addresses {
5537                planned.push(filter);
5538                continue;
5539            }
5540            addresses.sort_unstable();
5541            for chunk in addresses.chunks(max_addresses) {
5542                let mut split = filter.clone();
5543                split.address = FilterSet::default();
5544                for address in chunk {
5545                    split.address.insert(*address);
5546                }
5547                planned.push(split);
5548            }
5549        }
5550        planned
5551    }
5552
5553    /// Drop source-id and anchor bookkeeping for filters no longer referenced
5554    /// by any base or owner interest, so long-lived owner churn cannot grow the
5555    /// maps unboundedly. Live streams for retired filters are pruned by the
5556    /// next reconcile.
5557    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
5558    // `mutable_key_type` lint is a known false positive for it.
5559    #[allow(clippy::mutable_key_type)]
5560    fn retire_unreferenced_filters(&mut self) {
5561        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
5562        if let AlloySubscriberState::Active(streams) = &self.state {
5563            for entry in &streams.entries {
5564                match &entry.source {
5565                    SubscriberStreamSource::PubSubLog { filter, .. }
5566                    | SubscriberStreamSource::PollingLog { filter } => {
5567                        live.insert(filter.clone());
5568                    }
5569                    SubscriberStreamSource::PubSubPendingHashes
5570                    | SubscriberStreamSource::PubSubBlockHeaders
5571                    | SubscriberStreamSource::PollingPendingHashes => {}
5572                }
5573            }
5574        }
5575        self.log_source_ids
5576            .retain(|filter, _| live.contains(filter));
5577        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
5578        self.last_seen_log_blocks
5579            .retain(|id, _| live_ids.contains(id));
5580    }
5581
5582    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
5583        if self.pending_records.is_empty() {
5584            return None;
5585        }
5586
5587        let len = self.config.max_batch_size.min(self.pending_records.len());
5588        let records = self.pending_records.drain(..len).collect();
5589        Some(SubscriberInputBatch { records })
5590    }
5591
5592    fn reset_delivery_state(&mut self) {
5593        self.pending_records.clear();
5594        self.pending_reconcile_owner_records.clear();
5595        self.last_seen_log_blocks.clear();
5596        self.recent_input_refs.clear();
5597        self.recent_input_ref_set.clear();
5598        self.recent_owner_input_refs.clear();
5599        self.recent_owner_input_ref_sets.clear();
5600        self.pending_backfills.clear();
5601        self.log_source_ids.clear();
5602        self.next_log_source_id = 0;
5603        self.sources_dirty = true;
5604    }
5605
5606    fn bump_stream_revision(&mut self) {
5607        self.stream_revision = self.stream_revision.saturating_add(1);
5608    }
5609}
5610
5611impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
5612where
5613    P: Provider<N> + Send + Sync,
5614    N: Network + 'static,
5615    N::HeaderResponse: Send + 'static,
5616{
5617    fn add_interest_owner(
5618        &mut self,
5619        owner: HandlerId,
5620        interests: &[ReactiveInterest<N>],
5621    ) -> Result<(), SubscriberError> {
5622        AlloySubscriber::add_interest_owner(self, owner, interests)
5623    }
5624
5625    fn add_interest_owner_with_backfill(
5626        &mut self,
5627        owner: HandlerId,
5628        interests: &[ReactiveInterest<N>],
5629        backfill: SubscriberBackfill,
5630    ) -> Result<(), SubscriberError> {
5631        AlloySubscriber::add_interest_owner_with_backfill(self, owner, interests, backfill)
5632    }
5633
5634    fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
5635        AlloySubscriber::remove_interest_owner(self, owner)
5636    }
5637
5638    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
5639        AlloySubscriber::owner_interests(self, owner)
5640    }
5641}
5642
5643enum AlloySubscriberState<N: Network> {
5644    Uninitialized,
5645    Active(SubscriberStreams<N>),
5646    Empty,
5647}
5648
5649struct SubscriberStreams<N: Network> {
5650    entries: Vec<SubscriberStreamEntry<N>>,
5651    next_index: usize,
5652}
5653
5654struct SubscriberStreamEntry<N: Network> {
5655    source: SubscriberStreamSource,
5656    stream: BoxStream<'static, SubscriberEvent<N>>,
5657}
5658
5659impl<N: Network> SubscriberStreams<N> {
5660    fn new() -> Self {
5661        Self {
5662            entries: Vec::new(),
5663            next_index: 0,
5664        }
5665    }
5666
5667    fn is_empty(&self) -> bool {
5668        self.entries.is_empty()
5669    }
5670
5671    fn push(
5672        &mut self,
5673        source: SubscriberStreamSource,
5674        stream: BoxStream<'static, SubscriberEvent<N>>,
5675    ) {
5676        self.entries.push(SubscriberStreamEntry { source, stream });
5677    }
5678
5679    #[cfg(all(test, feature = "reactive-ws"))]
5680    fn len(&self) -> usize {
5681        self.entries.len()
5682    }
5683
5684    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
5685        self.entries
5686            .iter()
5687            .any(|entry| entry.source.same_key(source))
5688    }
5689
5690    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
5691        self.entries
5692            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
5693        self.normalize_next_index();
5694    }
5695
5696    fn normalize_next_index(&mut self) {
5697        if self.entries.is_empty() {
5698            self.next_index = 0;
5699        } else if self.next_index >= self.entries.len() {
5700            self.next_index %= self.entries.len();
5701        }
5702    }
5703
5704    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
5705        poll_fn(|cx| {
5706            self.normalize_next_index();
5707            if self.entries.is_empty() {
5708                return std::task::Poll::Ready(None);
5709            }
5710
5711            let mut index = self.next_index;
5712            let mut checked = 0usize;
5713            while checked < self.entries.len() {
5714                if index >= self.entries.len() {
5715                    index = 0;
5716                }
5717                match self.entries[index].stream.as_mut().poll_next(cx) {
5718                    std::task::Poll::Ready(Some(event)) => {
5719                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
5720                            self.entries.remove(index);
5721                            self.next_index = if self.entries.is_empty() {
5722                                0
5723                            } else {
5724                                index % self.entries.len()
5725                            };
5726                        } else {
5727                            self.next_index = (index + 1) % self.entries.len();
5728                        }
5729                        return std::task::Poll::Ready(Some(event));
5730                    }
5731                    std::task::Poll::Ready(None) => {
5732                        self.entries.remove(index);
5733                        if self.entries.is_empty() {
5734                            self.next_index = 0;
5735                            return std::task::Poll::Ready(None);
5736                        }
5737                    }
5738                    std::task::Poll::Pending => {
5739                        checked += 1;
5740                        index += 1;
5741                    }
5742                }
5743            }
5744
5745            if self.entries.is_empty() {
5746                std::task::Poll::Ready(None)
5747            } else {
5748                self.next_index = index % self.entries.len();
5749                std::task::Poll::Pending
5750            }
5751        })
5752        .await
5753    }
5754}
5755
5756#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5757#[allow(dead_code)]
5758enum SubscriberTransport {
5759    PubSub,
5760    Polling,
5761}
5762
5763#[derive(Clone, Debug)]
5764enum SubscriberStreamSource {
5765    PubSubLog { id: usize, filter: Filter },
5766    PubSubPendingHashes,
5767    PubSubBlockHeaders,
5768    PollingLog { filter: Filter },
5769    PollingPendingHashes,
5770}
5771
5772impl SubscriberStreamSource {
5773    fn label(&self) -> &'static str {
5774        match self {
5775            Self::PubSubLog { .. } => "pubsub log",
5776            Self::PubSubPendingHashes => "pubsub pending transaction hash",
5777            Self::PubSubBlockHeaders => "pubsub block header",
5778            Self::PollingLog { .. } => "polling log",
5779            Self::PollingPendingHashes => "polling pending transaction hash",
5780        }
5781    }
5782
5783    fn is_pubsub(&self) -> bool {
5784        matches!(
5785            self,
5786            Self::PubSubLog { .. } | Self::PubSubPendingHashes | Self::PubSubBlockHeaders
5787        )
5788    }
5789
5790    fn same_key(&self, other: &Self) -> bool {
5791        match (self, other) {
5792            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
5793            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
5794                left == right
5795            }
5796            (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
5797            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
5798            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
5799            _ => false,
5800        }
5801    }
5802}
5803
5804#[allow(dead_code)]
5805enum SubscriberEvent<N: Network> {
5806    Log { source_id: usize, log: Log },
5807    BackfilledLogs { source_id: usize, logs: Vec<Log> },
5808    Logs(Vec<Log>),
5809    BlockHeader(N::HeaderResponse),
5810    PendingHash(B256),
5811    PendingHashes(Vec<B256>),
5812    StreamTerminated(SubscriberStreamSource),
5813}
5814
5815impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
5816where
5817    P: Provider<N> + Send + Sync,
5818    N: Network + 'static,
5819    N::HeaderResponse: Send + 'static,
5820{
5821    fn register_interests(
5822        &mut self,
5823        interests: &[ReactiveInterest<N>],
5824    ) -> Result<(), SubscriberError> {
5825        validate_subscriber_config(&self.config)?;
5826        validate_supported_interests(self.mode, &self.config, interests)?;
5827
5828        self.base_interests = interests.to_vec();
5829        self.owned_interests.clear();
5830        self.rebuild_registered_interests();
5831        self.reset_delivery_state();
5832        self.state = AlloySubscriberState::Uninitialized;
5833        Ok(())
5834    }
5835
5836    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
5837        Box::pin(async {
5838            Ok(self
5839                .next_scoped_batch()
5840                .await?
5841                .map(SubscriberInputBatch::into_reactive_batch))
5842        })
5843    }
5844}
5845
5846impl<P, N> AlloySubscriber<P, N>
5847where
5848    P: Provider<N> + Send + Sync,
5849    N: Network + 'static,
5850    N::HeaderResponse: Send + 'static,
5851{
5852    /// Subscribe first, then catch an exact staged owner up through a verified
5853    /// canonical block.
5854    ///
5855    /// This compatibility wrapper delegates to
5856    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
5857    /// driver adopting several owners should call the bulk API once rather than
5858    /// invoking this method in a loop.
5859    pub async fn reconcile_interest_owner(
5860        &mut self,
5861        epoch: &SubscriberOwnerEpoch,
5862        through: BlockRef,
5863    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
5864    where
5865        P: Clone,
5866    {
5867        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
5868            .await?
5869            .pop()
5870            .ok_or(SubscriberOwnerError::NotStaged)
5871    }
5872
5873    /// Subscribe first, then atomically catch staged owners up through one
5874    /// verified canonical block.
5875    ///
5876    /// All epochs are preflighted before provider I/O. Live streams are
5877    /// reconciled once, compatible provider filters are merged into bounded
5878    /// chunks, and every historical request shares one double target-header
5879    /// certification. Provider-filter supersets are routed back through each
5880    /// owner's exact interests, retaining owner-scoped delivery provenance.
5881    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
5882    ///
5883    /// Live events are continuously drained while an independent provider
5884    /// clone performs catch-up. Fetched owner records and progress become
5885    /// visible only after every request and the final certification succeed. A
5886    /// failure leaves every target staged with its prior progress unchanged;
5887    /// live canonical delivery consumed during the attempt is preserved while
5888    /// excluding the failed target epochs from its staged-owner audience.
5889    pub async fn reconcile_interest_owners(
5890        &mut self,
5891        epochs: &[SubscriberOwnerEpoch],
5892        through: BlockRef,
5893    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
5894    where
5895        P: Clone,
5896    {
5897        if epochs.is_empty() {
5898            return Ok(Vec::new());
5899        }
5900
5901        let mut seen = HashSet::new();
5902        let mut plans = Vec::with_capacity(epochs.len());
5903        for epoch in epochs {
5904            if !seen.insert(epoch.clone()) {
5905                continue;
5906            }
5907            let entry = self
5908                .owned_interests
5909                .iter()
5910                .find(|entry| {
5911                    entry.epoch.as_ref() == Some(epoch)
5912                        && entry.state == SubscriberOwnerState::Staged
5913                })
5914                .ok_or(SubscriberOwnerError::NotStaged)?;
5915            let position = entry
5916                .progress
5917                .as_ref()
5918                .map(|progress| &progress.through)
5919                .or(entry.baseline.as_ref())
5920                .ok_or(SubscriberOwnerError::MissingBaseline)?;
5921            let baseline = position.number;
5922            if through.number < baseline {
5923                return Err(SubscriberOwnerError::ProgressRegression {
5924                    current: baseline,
5925                    target: through.number,
5926                });
5927            }
5928            let from_block = baseline
5929                .checked_add(1)
5930                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
5931            if through.number == baseline && through.hash != position.hash {
5932                return Err(SubscriberOwnerError::ProgressConflict {
5933                    number: baseline,
5934                    current_hash: position.hash,
5935                    target_hash: through.hash,
5936                });
5937            }
5938            if through.number == from_block
5939                && through
5940                    .parent_hash
5941                    .is_some_and(|parent| parent != position.hash)
5942            {
5943                return Err(SubscriberOwnerError::ProgressConflict {
5944                    number: baseline,
5945                    current_hash: position.hash,
5946                    target_hash: through.parent_hash.expect("checked as present above"),
5947                });
5948            }
5949            if entry
5950                .interests
5951                .iter()
5952                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
5953            {
5954                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
5955            }
5956            plans.push(SubscriberOwnerReconcilePlan {
5957                epoch: epoch.clone(),
5958                interests: entry.interests.clone(),
5959                retained: position.clone(),
5960                from_block,
5961            });
5962        }
5963
5964        // The ordering is intentional and part of the public continuity
5965        // contract: connect first, then fetch the bounded historical window.
5966        self.ensure_streams().await?;
5967        let provider = self.provider.clone();
5968        let filters = merged_owner_reconcile_filters(&plans, through.number);
5969        let retained = plans.iter().map(|plan| plan.retained.clone()).collect();
5970        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
5971        let fetch = fetch_owner_catchup::<P, N>(provider, filters, retained, through);
5972        let SubscriberOwnerCatchup { logs, certified } =
5973            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
5974
5975        self.pending_backfills.retain(|queued| {
5976            queued
5977                .epoch
5978                .as_ref()
5979                .is_none_or(|epoch| !target_epochs.contains(epoch))
5980        });
5981        let records = logs
5982            .into_iter()
5983            .map(|log| log_input_record(log, InputSource::Backfill))
5984            .collect();
5985        for record in dedupe_records(sort_records(records)) {
5986            let block_number = match &record.input {
5987                ReactiveInput::Log(log) => log
5988                    .block_number
5989                    .expect("bulk catch-up logs were validated before commit"),
5990                _ => unreachable!("bulk owner catch-up contains log records only"),
5991            };
5992            let owners = plans
5993                .iter()
5994                .filter(|plan| block_number >= plan.from_block)
5995                .filter(|plan| {
5996                    plan.interests
5997                        .iter()
5998                        .any(|interest| interest_matches(interest, &record.input))
5999                })
6000                .map(|plan| plan.epoch.clone())
6001                .collect();
6002            self.enqueue_owner_record_for_owners_unmerged(record, owners);
6003        }
6004        self.promote_reconcile_owner_records(&target_epochs);
6005        self.seed_reconciled_filter_anchors(&plans, certified.number);
6006
6007        let stream_revision = self.stream_revision;
6008        let mut progress = Vec::with_capacity(plans.len());
6009        for plan in plans {
6010            let item = SubscriberOwnerProgress {
6011                owner: plan.epoch.clone(),
6012                through: certified.clone(),
6013            };
6014            let entry = self
6015                .owned_interests
6016                .iter_mut()
6017                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
6018                .expect("bulk reconcile holds exclusive access after epoch preflight");
6019            entry.progress = Some(item.clone());
6020            entry.progress_stream_revision = Some(stream_revision);
6021            progress.push(item);
6022        }
6023        Ok(progress)
6024    }
6025
6026    async fn drive_reconcile_fetch<T, F>(
6027        &mut self,
6028        fetch: F,
6029        target_epochs: &HashSet<SubscriberOwnerEpoch>,
6030    ) -> Result<T, SubscriberOwnerError>
6031    where
6032        F: Future<Output = Result<T, SubscriberOwnerError>>,
6033    {
6034        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
6035            return fetch.await;
6036        }
6037        let mut fetch = Box::pin(fetch);
6038        loop {
6039            let event = {
6040                let live = Box::pin(self.next_event());
6041                match select(fetch, live).await {
6042                    Either::Left((result, pending_live)) => {
6043                        drop(pending_live);
6044                        return result;
6045                    }
6046                    Either::Right((event, pending_fetch)) => {
6047                        fetch = pending_fetch;
6048                        event
6049                    }
6050                }
6051            };
6052            let event = event?.ok_or_else(|| {
6053                SubscriberError::Provider(
6054                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
6055                )
6056            })?;
6057            self.buffer_reconcile_event_for_owners(&event, target_epochs);
6058            self.enqueue_event_excluding_owners(event, target_epochs);
6059        }
6060    }
6061
6062    /// Poll one driver control future with priority over the next scoped batch.
6063    ///
6064    /// This is the supported control-interleaving primitive for a subscriber
6065    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
6066    /// the caller's pending control future alive. When control wins, the
6067    /// in-progress subscriber poll is cancelled at a documented safe boundary:
6068    /// queued records are removed only when a complete batch is returned,
6069    /// successful backfill steps are committed before the next await, provider
6070    /// streams created but not installed are dropped, and installed streams
6071    /// remain owned by the subscriber for the next call.
6072    ///
6073    /// The control future is polled first. Therefore a ready shutdown/removal
6074    /// command cannot starve behind a continuously ready subscriber queue.
6075    pub async fn next_scoped_batch_or<C, F>(
6076        &mut self,
6077        control: Pin<&mut F>,
6078    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
6079    where
6080        C: Send,
6081        F: Future<Output = C> + Send,
6082    {
6083        let batch = self.next_scoped_batch();
6084        match select(control, batch).await {
6085            Either::Left((control, pending_batch)) => {
6086                drop(pending_batch);
6087                Ok(SubscriberDriverPoll::Control(control))
6088            }
6089            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
6090        }
6091    }
6092
6093    /// Return the next subscriber batch while retaining staged-owner delivery
6094    /// provenance captured at enqueue time.
6095    ///
6096    /// Transaction-aware drivers must use this method. The compatibility
6097    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
6098    /// its historical behavior for existing callers.
6099    ///
6100    /// For command interleaving, prefer
6101    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
6102    /// cancellation-safety invariants of this poll and prioritizes ready control.
6103    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
6104        Box::pin(async {
6105            if let Some(batch) = self.drain_next_scoped_batch() {
6106                return Ok(Some(batch));
6107            }
6108
6109            self.drain_pending_backfills().await?;
6110            if let Some(batch) = self.drain_next_scoped_batch() {
6111                return Ok(Some(batch));
6112            }
6113
6114            self.ensure_streams().await?;
6115            if let Some(batch) = self.drain_next_scoped_batch() {
6116                return Ok(Some(batch));
6117            }
6118
6119            if self.interests.is_empty() {
6120                return Ok(None);
6121            }
6122
6123            loop {
6124                let Some(event) = self.next_event().await? else {
6125                    return Ok(None);
6126                };
6127
6128                self.enqueue_event(event);
6129                if let Some(batch) = self.drain_next_scoped_batch() {
6130                    return Ok(Some(batch));
6131                }
6132            }
6133        })
6134    }
6135
6136    /// Bring live streams in line with the current interest set.
6137    ///
6138    /// Runs incrementally: the desired-vs-live diff only happens when interest
6139    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
6140    /// steady-state polling costs nothing here. Missing sources are connected,
6141    /// sources for retired filters are dropped (dropping an Alloy subscription
6142    /// unsubscribes provider-side), and unrelated live streams — with their
6143    /// delivery and anchor state — are left untouched.
6144    ///
6145    /// A newly connected log source whose filter already has a delivery anchor
6146    /// is caught up from that anchor immediately after subscribing (the same
6147    /// subscribe-then-backfill order the reconnect path uses). Together with
6148    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
6149    /// window between an adoption backfill and live stream start.
6150    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
6151        if !self.sources_dirty {
6152            return Ok(());
6153        }
6154        // An interest-less subscriber never touches the provider
6155        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
6156        // the empty desired topology as clean so a deliberately empty staged
6157        // epoch can reconcile and activate instead of remaining dirty forever.
6158        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
6159            self.bump_stream_revision();
6160            self.sources_dirty = false;
6161            return Ok(());
6162        }
6163
6164        let desired = self.stream_sources()?;
6165        let missing: Vec<SubscriberStreamSource> = match &self.state {
6166            AlloySubscriberState::Active(streams) => desired
6167                .iter()
6168                .filter(|source| !streams.contains_source(source))
6169                .cloned()
6170                .collect(),
6171            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
6172        };
6173
6174        let mut connected = Vec::new();
6175        for source in missing {
6176            let stream = self.connect_source_stream(source.clone()).await?;
6177            // Anchored catch-up for a source with a known delivery watermark
6178            // (seeded by a drained adoption backfill, or inherited from a
6179            // filter shape that was live before): subscribe first, then fetch
6180            // the gap, so nothing lands between the two.
6181            if let Some(event) = self.backfill_reconnected_source(&source).await? {
6182                self.enqueue_event(event);
6183            }
6184            connected.push((source, stream));
6185        }
6186
6187        match &mut self.state {
6188            AlloySubscriberState::Active(streams) => {
6189                streams.retain_sources(&desired);
6190                for (source, stream) in connected {
6191                    streams.push(source, stream);
6192                }
6193                if streams.is_empty() {
6194                    self.state = AlloySubscriberState::Empty;
6195                }
6196            }
6197            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
6198                let mut streams = SubscriberStreams::new();
6199                for (source, stream) in connected {
6200                    streams.push(source, stream);
6201                }
6202                self.state = if streams.is_empty() {
6203                    AlloySubscriberState::Empty
6204                } else {
6205                    AlloySubscriberState::Active(streams)
6206                };
6207            }
6208        }
6209
6210        self.bump_stream_revision();
6211        self.sources_dirty = false;
6212        self.retire_unreferenced_filters();
6213        Ok(())
6214    }
6215
6216    /// Fetch queued adoption/continuity backfills, oldest first.
6217    ///
6218    /// An entry is consumed only after its `get_logs` fetch succeeds — a
6219    /// transient RPC failure surfaces the error and leaves the entry queued for
6220    /// the next poll, so a flaky request cannot silently discard the missed
6221    /// window the backfill exists to close. Open-ended backfills resolve their
6222    /// upper bound to the provider's current head before fetching, and every
6223    /// drained backfill advances the filter's delivery anchor to that bound —
6224    /// even a zero-log window — so the filter is reconnect-protected from then
6225    /// on. Draining pauses as soon as records are ready for delivery; remaining
6226    /// entries stay queued.
6227    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
6228        while let Some(queued) = self.pending_backfills.front() {
6229            // Owner was removed while its backfill was queued.
6230            let epoch = queued.epoch.clone();
6231            let owner_exists = match &epoch {
6232                Some(epoch) => self.interest_owner_state(epoch).is_some(),
6233                None => self.owner_interests(&queued.owner).is_some(),
6234            };
6235            if !owner_exists {
6236                self.pending_backfills.pop_front();
6237                continue;
6238            }
6239            let filter = queued.filter.clone();
6240            let backfill = queued.backfill;
6241
6242            let to_block = match backfill.end_block() {
6243                Some(to_block) => to_block,
6244                None => self
6245                    .provider
6246                    .get_block_number()
6247                    .await
6248                    .map_err(provider_error)?,
6249            };
6250            if to_block < backfill.start_block() {
6251                // Anchor already at (or past) the provider head: nothing to
6252                // fetch, and the anchor keeps its current value.
6253                self.pending_backfills.pop_front();
6254                continue;
6255            }
6256
6257            let range = filter
6258                .clone()
6259                .from_block(backfill.start_block())
6260                .to_block(to_block);
6261            let logs = self
6262                .provider
6263                .get_logs(&range)
6264                .await
6265                .map_err(provider_error)?;
6266
6267            // Fetch succeeded: consume the entry, deliver, and advance the
6268            // anchor through the fetched bound.
6269            self.pending_backfills.pop_front();
6270            let source_id = self.log_source_id(&filter);
6271            self.enqueue_backfilled_logs(logs, Some(source_id), epoch.as_ref(), Some(backfill));
6272            if epoch.is_none() {
6273                let anchor = self
6274                    .last_seen_log_blocks
6275                    .entry(source_id)
6276                    .or_insert(to_block);
6277                *anchor = (*anchor).max(to_block);
6278            }
6279
6280            if !self.pending_records.is_empty() {
6281                break;
6282            }
6283        }
6284        Ok(())
6285    }
6286
6287    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
6288        match resolve_subscriber_transport(self.mode)? {
6289            SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()),
6290            SubscriberTransport::Polling => Ok(self.polling_stream_sources()),
6291        }
6292    }
6293
6294    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
6295        let mut sources = Vec::new();
6296        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
6297
6298        for filter in self.log_stream_filters() {
6299            let id = self.log_source_id(&filter);
6300            if let Some(anchor) = inherited_anchor {
6301                self.last_seen_log_blocks.entry(id).or_insert(anchor);
6302            }
6303            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
6304        }
6305
6306        if needs_pending_hash_stream(&self.interests) {
6307            sources.push(SubscriberStreamSource::PubSubPendingHashes);
6308        }
6309
6310        if needs_header_block_stream(&self.interests) {
6311            sources.push(SubscriberStreamSource::PubSubBlockHeaders);
6312        }
6313
6314        sources
6315    }
6316
6317    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
6318        let mut sources = Vec::new();
6319
6320        for filter in self.log_stream_filters() {
6321            sources.push(SubscriberStreamSource::PollingLog { filter });
6322        }
6323
6324        if needs_pending_hash_stream(&self.interests) {
6325            sources.push(SubscriberStreamSource::PollingPendingHashes);
6326        }
6327
6328        sources
6329    }
6330
6331    fn log_source_id(&mut self, filter: &Filter) -> usize {
6332        if let Some(id) = self.log_source_ids.get(filter) {
6333            return *id;
6334        }
6335
6336        let id = self.next_log_source_id;
6337        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
6338        self.log_source_ids.insert(filter.clone(), id);
6339        id
6340    }
6341
6342    async fn connect_source_stream(
6343        &mut self,
6344        source: SubscriberStreamSource,
6345    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6346        match source {
6347            SubscriberStreamSource::PubSubLog { id, filter } => {
6348                self.connect_pubsub_log_stream(id, filter).await
6349            }
6350            SubscriberStreamSource::PubSubPendingHashes => {
6351                self.connect_pubsub_pending_hash_stream().await
6352            }
6353            SubscriberStreamSource::PubSubBlockHeaders => {
6354                self.connect_pubsub_block_header_stream().await
6355            }
6356            SubscriberStreamSource::PollingLog { filter } => {
6357                self.connect_polling_log_stream(filter).await
6358            }
6359            SubscriberStreamSource::PollingPendingHashes => {
6360                self.connect_polling_pending_hash_stream().await
6361            }
6362        }
6363    }
6364
6365    async fn connect_pubsub_log_stream(
6366        &mut self,
6367        id: usize,
6368        filter: Filter,
6369    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6370        #[cfg(feature = "reactive-ws")]
6371        {
6372            let source = SubscriberStreamSource::PubSubLog {
6373                id,
6374                filter: filter.clone(),
6375            };
6376            let stream = self
6377                .provider
6378                .subscribe_logs(&filter)
6379                .channel_size(self.config.max_batch_size.max(1))
6380                .await
6381                .map_err(provider_error)?
6382                .into_stream()
6383                .map(move |log| SubscriberEvent::Log { source_id: id, log });
6384            Ok(stream_with_termination(stream, source))
6385        }
6386
6387        #[cfg(not(feature = "reactive-ws"))]
6388        {
6389            let _ = (id, filter);
6390            Err(SubscriberError::Unsupported(
6391                "AlloySubscriber pubsub mode requires the reactive-ws feature",
6392            ))
6393        }
6394    }
6395
6396    async fn connect_pubsub_pending_hash_stream(
6397        &mut self,
6398    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6399        #[cfg(feature = "reactive-ws")]
6400        {
6401            let stream = self
6402                .provider
6403                .subscribe_pending_transactions()
6404                .channel_size(self.config.max_batch_size.max(1))
6405                .await
6406                .map_err(provider_error)?
6407                .into_stream()
6408                .map(SubscriberEvent::PendingHash);
6409            Ok(stream_with_termination(
6410                stream,
6411                SubscriberStreamSource::PubSubPendingHashes,
6412            ))
6413        }
6414
6415        #[cfg(not(feature = "reactive-ws"))]
6416        {
6417            Err(SubscriberError::Unsupported(
6418                "AlloySubscriber pubsub mode requires the reactive-ws feature",
6419            ))
6420        }
6421    }
6422
6423    async fn connect_pubsub_block_header_stream(
6424        &mut self,
6425    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6426        #[cfg(feature = "reactive-ws")]
6427        {
6428            let stream = self
6429                .provider
6430                .subscribe_blocks()
6431                .channel_size(self.config.max_batch_size.max(1))
6432                .await
6433                .map_err(provider_error)?
6434                .into_stream()
6435                .map(SubscriberEvent::BlockHeader);
6436            Ok(stream_with_termination(
6437                stream,
6438                SubscriberStreamSource::PubSubBlockHeaders,
6439            ))
6440        }
6441
6442        #[cfg(not(feature = "reactive-ws"))]
6443        {
6444            Err(SubscriberError::Unsupported(
6445                "AlloySubscriber pubsub mode requires the reactive-ws feature",
6446            ))
6447        }
6448    }
6449
6450    async fn connect_polling_log_stream(
6451        &mut self,
6452        filter: Filter,
6453    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6454        #[cfg(feature = "reactive-polling")]
6455        {
6456            let source = SubscriberStreamSource::PollingLog {
6457                filter: filter.clone(),
6458            };
6459            let stream = self
6460                .provider
6461                .watch_logs(&filter)
6462                .await
6463                .map_err(provider_error)?
6464                .with_channel_size(self.config.max_batch_size.max(1))
6465                .into_stream()
6466                .map(SubscriberEvent::Logs);
6467            Ok(stream_with_termination(stream, source))
6468        }
6469
6470        #[cfg(not(feature = "reactive-polling"))]
6471        {
6472            let _ = filter;
6473            Err(SubscriberError::Unsupported(
6474                "AlloySubscriber polling mode requires the reactive-polling feature",
6475            ))
6476        }
6477    }
6478
6479    async fn connect_polling_pending_hash_stream(
6480        &mut self,
6481    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
6482        #[cfg(feature = "reactive-polling")]
6483        {
6484            let stream = self
6485                .provider
6486                .watch_pending_transactions()
6487                .await
6488                .map_err(provider_error)?
6489                .with_channel_size(self.config.max_batch_size.max(1))
6490                .into_stream()
6491                .map(SubscriberEvent::PendingHashes);
6492            Ok(stream_with_termination(
6493                stream,
6494                SubscriberStreamSource::PollingPendingHashes,
6495            ))
6496        }
6497
6498        #[cfg(not(feature = "reactive-polling"))]
6499        {
6500            Err(SubscriberError::Unsupported(
6501                "AlloySubscriber polling mode requires the reactive-polling feature",
6502            ))
6503        }
6504    }
6505
6506    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
6507        loop {
6508            let event = match &mut self.state {
6509                AlloySubscriberState::Active(streams) => streams.next().await,
6510                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
6511                    return Ok(None);
6512                }
6513            };
6514
6515            let Some(event) = event else {
6516                return Err(SubscriberError::Provider(
6517                    "Alloy subscriber streams terminated before the subscriber was stopped"
6518                        .to_owned(),
6519                ));
6520            };
6521
6522            match event {
6523                SubscriberEvent::StreamTerminated(source) => {
6524                    // Persist the missing-source intent before the first await.
6525                    // If a control command cancels this poll during reconnect,
6526                    // the next poll will reconcile the desired/live diff.
6527                    self.sources_dirty = true;
6528                    self.bump_stream_revision();
6529                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
6530                        self.sources_dirty = false;
6531                        return Ok(Some(backfill_event));
6532                    }
6533                    self.sources_dirty = false;
6534                }
6535                event => return Ok(Some(event)),
6536            }
6537        }
6538    }
6539
6540    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
6541        self.enqueue_event_with_excluded_owners(event, None);
6542    }
6543
6544    fn buffer_reconcile_event_for_owners(
6545        &mut self,
6546        event: &SubscriberEvent<N>,
6547        target_epochs: &HashSet<SubscriberOwnerEpoch>,
6548    ) {
6549        match event {
6550            SubscriberEvent::Log { log, .. } => {
6551                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
6552            }
6553            SubscriberEvent::BackfilledLogs { logs, .. } => {
6554                for log in logs {
6555                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
6556                }
6557            }
6558            SubscriberEvent::Logs(logs) => {
6559                for log in logs {
6560                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
6561                }
6562            }
6563            SubscriberEvent::BlockHeader(_)
6564            | SubscriberEvent::PendingHash(_)
6565            | SubscriberEvent::PendingHashes(_)
6566            | SubscriberEvent::StreamTerminated(_) => {}
6567        }
6568    }
6569
6570    fn buffer_reconcile_log_for_owners(
6571        &mut self,
6572        log: &Log,
6573        source: InputSource,
6574        target_epochs: &HashSet<SubscriberOwnerEpoch>,
6575    ) {
6576        let record = log_input_record(log.clone(), source);
6577        let owners = self
6578            .staged_owners_for_record(&record)
6579            .into_iter()
6580            .filter(|owner| target_epochs.contains(owner))
6581            .collect::<Vec<_>>();
6582        if !owners.is_empty() {
6583            self.pending_reconcile_owner_records
6584                .push_back(BufferedSubscriberOwnerRecord { record, owners });
6585        }
6586    }
6587
6588    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
6589        let mut retained = VecDeque::new();
6590        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
6591            let mut promoted = Vec::new();
6592            buffered.owners.retain(|owner| {
6593                if target_epochs.contains(owner) {
6594                    promoted.push(owner.clone());
6595                    false
6596                } else {
6597                    true
6598                }
6599            });
6600            if promoted.is_empty() {
6601                retained.push_back(buffered);
6602                continue;
6603            }
6604            let promoted_record = if buffered.owners.is_empty() {
6605                buffered.record
6606            } else {
6607                let record = buffered.record.clone();
6608                retained.push_back(buffered);
6609                record
6610            };
6611            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
6612        }
6613        self.pending_reconcile_owner_records = retained;
6614    }
6615
6616    fn seed_reconciled_filter_anchors(
6617        &mut self,
6618        plans: &[SubscriberOwnerReconcilePlan<N>],
6619        through: u64,
6620    ) {
6621        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
6622            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
6623                continue;
6624            };
6625            let anchor = self
6626                .last_seen_log_blocks
6627                .entry(source_id)
6628                .or_insert(through);
6629            *anchor = (*anchor).max(through);
6630        }
6631    }
6632
6633    fn enqueue_event_excluding_owners(
6634        &mut self,
6635        event: SubscriberEvent<N>,
6636        excluded: &HashSet<SubscriberOwnerEpoch>,
6637    ) {
6638        self.enqueue_event_with_excluded_owners(event, Some(excluded));
6639    }
6640
6641    fn enqueue_event_with_excluded_owners(
6642        &mut self,
6643        event: SubscriberEvent<N>,
6644        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
6645    ) {
6646        match event {
6647            SubscriberEvent::Log { source_id, log } => {
6648                if log_matches_any_interest(&log, &self.interests) {
6649                    let record = log_input_record(log, InputSource::Subscription);
6650                    self.note_log_block(source_id, &record);
6651                    self.enqueue_record_with_excluded_owners(record, excluded);
6652                }
6653            }
6654            SubscriberEvent::BackfilledLogs { source_id, logs } => {
6655                self.enqueue_backfilled_logs_with_excluded_owners(
6656                    logs,
6657                    Some(source_id),
6658                    None,
6659                    None,
6660                    excluded,
6661                );
6662            }
6663            SubscriberEvent::Logs(logs) => {
6664                for log in logs {
6665                    if log_matches_any_interest(&log, &self.interests) {
6666                        self.enqueue_record_with_excluded_owners(
6667                            log_input_record(log, InputSource::Poll),
6668                            excluded,
6669                        );
6670                    }
6671                }
6672            }
6673            SubscriberEvent::BlockHeader(header) => {
6674                if needs_header_block_stream(&self.interests) {
6675                    let record = block_header_input_record::<N>(header);
6676                    self.enqueue_record_with_excluded_owners(record, excluded);
6677                }
6678            }
6679            SubscriberEvent::PendingHash(hash) => {
6680                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
6681                self.enqueue_record_with_excluded_owners(record, excluded);
6682            }
6683            SubscriberEvent::PendingHashes(hashes) => {
6684                for hash in hashes {
6685                    self.enqueue_record_with_excluded_owners(
6686                        pending_hash_input_record::<N>(hash, InputSource::Poll),
6687                        excluded,
6688                    );
6689                }
6690            }
6691            SubscriberEvent::StreamTerminated(_) => {}
6692        }
6693    }
6694
6695    fn enqueue_backfilled_logs(
6696        &mut self,
6697        logs: Vec<Log>,
6698        source_id: Option<usize>,
6699        owner: Option<&SubscriberOwnerEpoch>,
6700        range: Option<SubscriberBackfill>,
6701    ) {
6702        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
6703    }
6704
6705    fn enqueue_backfilled_logs_with_excluded_owners(
6706        &mut self,
6707        logs: Vec<Log>,
6708        source_id: Option<usize>,
6709        owner: Option<&SubscriberOwnerEpoch>,
6710        range: Option<SubscriberBackfill>,
6711        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
6712    ) {
6713        for log in logs {
6714            if range.is_some_and(|range| {
6715                log.block_number.is_some_and(|block| {
6716                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
6717                })
6718            }) {
6719                continue;
6720            }
6721            let matches = match owner {
6722                Some(epoch) => self
6723                    .owned_interests
6724                    .iter()
6725                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
6726                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
6727                None => log_matches_any_interest(&log, &self.interests),
6728            };
6729            if matches {
6730                let record = log_input_record(log, InputSource::Backfill);
6731                if let Some(epoch) = owner {
6732                    self.enqueue_owner_record(record, epoch.clone());
6733                } else {
6734                    if let Some(source_id) = source_id {
6735                        self.note_log_block(source_id, &record);
6736                    }
6737                    self.enqueue_record_with_excluded_owners(record, excluded);
6738                }
6739            }
6740        }
6741    }
6742
6743    async fn reconnect_source_stream(
6744        &mut self,
6745        source: SubscriberStreamSource,
6746    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
6747        if !source.is_pubsub() {
6748            return Err(stream_terminated_error(&source));
6749        }
6750
6751        if !self.config.reconnect.enabled {
6752            return Err(SubscriberError::Provider(format!(
6753                "Alloy subscriber {} stream terminated and reconnect is disabled",
6754                source.label()
6755            )));
6756        }
6757
6758        let mut attempts = 0usize;
6759        let mut delay = self.config.reconnect.initial_delay;
6760        let mut retry_delay = self.config.reconnect.retry_delay;
6761
6762        loop {
6763            attempts = attempts.saturating_add(1);
6764            if !delay.is_zero() {
6765                tokio::time::sleep(delay).await;
6766            }
6767
6768            match self.reconnect_source_once(source.clone()).await {
6769                Ok(backfill_event) => return Ok(backfill_event),
6770                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
6771                    return Err(SubscriberError::Provider(format!(
6772                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
6773                        source.label()
6774                    )));
6775                }
6776                Err(error) => {
6777                    tracing::warn!(
6778                        stream = source.label(),
6779                        attempts,
6780                        error = %error,
6781                        "Alloy subscriber reconnect attempt failed"
6782                    );
6783                    delay = retry_delay;
6784                    retry_delay =
6785                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
6786                }
6787            }
6788        }
6789    }
6790
6791    async fn reconnect_source_once(
6792        &mut self,
6793        source: SubscriberStreamSource,
6794    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
6795        let stream = self.connect_source_stream(source.clone()).await?;
6796        let backfill_event = self.backfill_reconnected_source(&source).await?;
6797
6798        match &mut self.state {
6799            AlloySubscriberState::Active(streams) => streams.push(source, stream),
6800            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
6801                return Err(SubscriberError::Provider(
6802                    "Alloy subscriber state changed before reconnect completed".to_owned(),
6803                ));
6804            }
6805        }
6806
6807        Ok(backfill_event)
6808    }
6809
6810    async fn backfill_reconnected_source(
6811        &mut self,
6812        source: &SubscriberStreamSource,
6813    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
6814        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
6815            return Ok(None);
6816        };
6817        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
6818            return Ok(None);
6819        };
6820
6821        let latest = self
6822            .provider
6823            .get_block_number()
6824            .await
6825            .map_err(provider_error)?;
6826        if latest < from_block {
6827            return Ok(None);
6828        }
6829
6830        let logs = self
6831            .provider
6832            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
6833            .await
6834            .map_err(provider_error)?;
6835        Ok(Some(SubscriberEvent::BackfilledLogs {
6836            source_id: *id,
6837            logs,
6838        }))
6839    }
6840
6841    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
6842        if let Some(block) = record.context.block.as_ref() {
6843            self.last_seen_log_blocks.insert(source_id, block.number);
6844        }
6845    }
6846
6847    fn enqueue_record_with_excluded_owners(
6848        &mut self,
6849        record: ReactiveInputRecord<N>,
6850        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
6851    ) {
6852        let mut owners = self.staged_owners_for_record(&record);
6853        if let Some(excluded) = excluded {
6854            owners.retain(|owner| !excluded.contains(owner));
6855        }
6856        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
6857        let owners = self.filter_recent_owner_duplicates(&record, owners);
6858        if canonical_duplicate {
6859            if !owners.is_empty() {
6860                self.pending_records.push_back(SubscriberInputRecord {
6861                    record,
6862                    scope: SubscriberInputScope::OwnerOnly { owners },
6863                });
6864            }
6865            return;
6866        }
6867        self.remember_record(&record);
6868        self.pending_records.push_back(SubscriberInputRecord {
6869            record,
6870            scope: SubscriberInputScope::Canonical { owners },
6871        });
6872    }
6873
6874    fn enqueue_owner_record(
6875        &mut self,
6876        record: ReactiveInputRecord<N>,
6877        owner: SubscriberOwnerEpoch,
6878    ) {
6879        self.enqueue_owner_record_for_owners(record, vec![owner]);
6880    }
6881
6882    fn enqueue_owner_record_for_owners(
6883        &mut self,
6884        record: ReactiveInputRecord<N>,
6885        owners: Vec<SubscriberOwnerEpoch>,
6886    ) {
6887        self.enqueue_owner_record_for_owners_inner(record, owners, true);
6888    }
6889
6890    fn enqueue_owner_record_for_owners_unmerged(
6891        &mut self,
6892        record: ReactiveInputRecord<N>,
6893        owners: Vec<SubscriberOwnerEpoch>,
6894    ) {
6895        self.enqueue_owner_record_for_owners_inner(record, owners, false);
6896    }
6897
6898    fn enqueue_owner_record_for_owners_inner(
6899        &mut self,
6900        record: ReactiveInputRecord<N>,
6901        owners: Vec<SubscriberOwnerEpoch>,
6902        merge_pending: bool,
6903    ) {
6904        let owners = self.filter_recent_owner_duplicates(&record, owners);
6905        if owners.is_empty() {
6906            return;
6907        }
6908        if merge_pending
6909            && should_dedupe_record(&record)
6910            && self.config.reconnect.dedupe_window != 0
6911        {
6912            let input_ref = record.input_ref();
6913            if let Some(pending) = self
6914                .pending_records
6915                .iter_mut()
6916                .rev()
6917                .find(|pending| pending.record.input_ref() == input_ref)
6918            {
6919                let pending_owners = match &mut pending.scope {
6920                    SubscriberInputScope::Canonical { owners }
6921                    | SubscriberInputScope::OwnerOnly { owners } => owners,
6922                };
6923                for owner in owners {
6924                    if !pending_owners.contains(&owner) {
6925                        pending_owners.push(owner);
6926                    }
6927                }
6928                return;
6929            }
6930        }
6931        self.pending_records.push_back(SubscriberInputRecord {
6932            record,
6933            scope: SubscriberInputScope::OwnerOnly { owners },
6934        });
6935    }
6936
6937    fn staged_owners_for_record(
6938        &self,
6939        record: &ReactiveInputRecord<N>,
6940    ) -> Vec<SubscriberOwnerEpoch> {
6941        self.owned_interests
6942            .iter()
6943            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
6944            .filter(|entry| {
6945                entry
6946                    .interests
6947                    .iter()
6948                    .any(|interest| interest_matches(interest, &record.input))
6949            })
6950            .filter_map(|entry| entry.epoch.clone())
6951            .collect()
6952    }
6953
6954    fn filter_recent_owner_duplicates(
6955        &mut self,
6956        record: &ReactiveInputRecord<N>,
6957        owners: Vec<SubscriberOwnerEpoch>,
6958    ) -> Vec<SubscriberOwnerEpoch> {
6959        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
6960            return owners;
6961        }
6962        let input_ref = record.input_ref();
6963        let window = self.config.reconnect.dedupe_window;
6964        owners
6965            .into_iter()
6966            .filter(|owner| {
6967                let seen = self
6968                    .recent_owner_input_ref_sets
6969                    .entry(owner.clone())
6970                    .or_default();
6971                if !seen.insert(input_ref) {
6972                    return false;
6973                }
6974                let recent = self
6975                    .recent_owner_input_refs
6976                    .entry(owner.clone())
6977                    .or_default();
6978                recent.push_back(input_ref);
6979                while recent.len() > window {
6980                    if let Some(evicted) = recent.pop_front() {
6981                        seen.remove(&evicted);
6982                    }
6983                }
6984                true
6985            })
6986            .collect()
6987    }
6988
6989    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
6990        if !should_dedupe_record(record) {
6991            return false;
6992        }
6993        self.recent_input_ref_set.contains(&record.input_ref())
6994    }
6995
6996    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
6997        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
6998            return;
6999        }
7000
7001        let input_ref = record.input_ref();
7002        if !self.recent_input_ref_set.insert(input_ref) {
7003            return;
7004        }
7005        self.recent_input_refs.push_back(input_ref);
7006
7007        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
7008            if let Some(evicted) = self.recent_input_refs.pop_front() {
7009                self.recent_input_ref_set.remove(&evicted);
7010            }
7011        }
7012    }
7013}
7014
7015#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))]
7016fn stream_with_termination<N, S>(
7017    stream: S,
7018    source: SubscriberStreamSource,
7019) -> BoxStream<'static, SubscriberEvent<N>>
7020where
7021    N: Network + 'static,
7022    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
7023{
7024    stream
7025        .chain(stream::once(async move {
7026            SubscriberEvent::StreamTerminated(source)
7027        }))
7028        .boxed()
7029}
7030
7031fn aggregate_interests<N: Network>(
7032    base: &[ReactiveInterest<N>],
7033    owned: &[OwnedSubscriberInterests<N>],
7034) -> Vec<ReactiveInterest<N>> {
7035    base.iter()
7036        .cloned()
7037        .chain(
7038            owned
7039                .iter()
7040                .flat_map(|entry| entry.interests.iter().cloned()),
7041        )
7042        .collect()
7043}
7044
7045fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
7046    SubscriberError::Provider(format!(
7047        "Alloy subscriber {} stream terminated before the subscriber was stopped",
7048        source.label()
7049    ))
7050}
7051
7052fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
7053    config
7054        .max_attempts
7055        .is_some_and(|max_attempts| attempts >= max_attempts)
7056}
7057
7058fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
7059    if current.is_zero() {
7060        return current;
7061    }
7062    current.checked_mul(2).unwrap_or(max).min(max)
7063}
7064
7065fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
7066    match &record.input {
7067        ReactiveInput::Log(log) => {
7068            is_canonical_status(&record.context.chain_status) && !log.removed
7069        }
7070        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
7071        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
7072    }
7073}
7074
7075#[cfg(test)]
7076mod subscriber_helper_tests {
7077    use super::*;
7078    use alloy_provider::ProviderBuilder;
7079    use alloy_transport::mock::Asserter;
7080
7081    fn rpc_log(removed: bool) -> Log {
7082        Log {
7083            inner: alloy_primitives::Log::new_unchecked(
7084                Address::repeat_byte(0x42),
7085                vec![B256::repeat_byte(0x01)],
7086                Bytes::new(),
7087            ),
7088            block_hash: Some(B256::repeat_byte(0x02)),
7089            block_number: Some(7),
7090            block_timestamp: Some(1_700_000_000),
7091            transaction_hash: Some(B256::repeat_byte(0x03)),
7092            transaction_index: Some(4),
7093            log_index: Some(5),
7094            removed,
7095        }
7096    }
7097
7098    #[tokio::test(flavor = "multi_thread")]
7099    async fn stream_with_termination_yields_terminal_source_marker() {
7100        let mut stream = stream_with_termination::<Ethereum, _>(
7101            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
7102                0xaa,
7103            ))]),
7104            SubscriberStreamSource::PubSubPendingHashes,
7105        );
7106
7107        assert!(matches!(
7108            stream.next().await,
7109            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
7110        ));
7111        assert!(matches!(
7112            stream.next().await,
7113            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
7114        ));
7115        assert!(stream.next().await.is_none());
7116    }
7117
7118    #[test]
7119    fn reconnect_delay_doubles_until_capped() {
7120        assert_eq!(
7121            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
7122            Duration::from_millis(500)
7123        );
7124        assert_eq!(
7125            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
7126            Duration::from_secs(1)
7127        );
7128        assert_eq!(
7129            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
7130            Duration::ZERO
7131        );
7132    }
7133
7134    #[test]
7135    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
7136        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
7137        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
7138
7139        assert!(should_dedupe_record(&included));
7140        assert!(!should_dedupe_record(&removed));
7141    }
7142
7143    #[test]
7144    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
7145        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7146        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7147            provider,
7148            SubscriberMode::Polling,
7149            SubscriberConfig::default(),
7150        );
7151        let owner = HandlerId::new("replace-owner");
7152        let original = ReactiveInterest::Logs(LogInterest {
7153            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
7154            local_matcher: None,
7155            route_key: None,
7156        });
7157        let replacement_interest = ReactiveInterest::Logs(LogInterest {
7158            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7159            local_matcher: None,
7160            route_key: None,
7161        });
7162        let active = subscriber
7163            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
7164            .unwrap();
7165        assert!(subscriber.activate_interest_owner(&active));
7166        let replacement = subscriber
7167            .stage_interest_owner_replacement(
7168                owner,
7169                &[replacement_interest],
7170                SubscriberOwnerStart::Live,
7171            )
7172            .unwrap();
7173
7174        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
7175        assert_eq!(subscriber.interest_owner_state(&active), None);
7176        assert_eq!(
7177            subscriber.interest_owner_state(&replacement),
7178            Some(SubscriberOwnerState::Active)
7179        );
7180        assert_eq!(subscriber.registered_interests().len(), 1);
7181    }
7182
7183    #[tokio::test(flavor = "multi_thread")]
7184    #[cfg(feature = "reactive-ws")]
7185    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
7186        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7187        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7188            provider,
7189            SubscriberMode::PubSub,
7190            SubscriberConfig::default(),
7191        );
7192        let interest = ReactiveInterest::Logs(LogInterest {
7193            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7194            local_matcher: None,
7195            route_key: None,
7196        });
7197        let owner_a = subscriber
7198            .stage_interest_owner(
7199                HandlerId::new("owner-a"),
7200                std::slice::from_ref(&interest),
7201                SubscriberOwnerStart::Live,
7202            )
7203            .unwrap();
7204        let owner_b = subscriber
7205            .stage_interest_owner(
7206                HandlerId::new("owner-b"),
7207                &[interest],
7208                SubscriberOwnerStart::Live,
7209            )
7210            .unwrap();
7211
7212        subscriber.enqueue_event(SubscriberEvent::Log {
7213            source_id: 0,
7214            log: rpc_log(false),
7215        });
7216        assert!(subscriber.abort_interest_owner(&owner_a));
7217
7218        let batch = subscriber
7219            .next_scoped_batch()
7220            .await
7221            .unwrap()
7222            .expect("shared canonical delivery remains queued");
7223        assert_eq!(batch.records.len(), 1);
7224        assert_eq!(
7225            batch.records[0].scope,
7226            SubscriberInputScope::Canonical {
7227                owners: vec![owner_b]
7228            }
7229        );
7230    }
7231
7232    #[tokio::test(flavor = "multi_thread")]
7233    #[cfg(feature = "reactive-ws")]
7234    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
7235        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7236        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7237            provider,
7238            SubscriberMode::PubSub,
7239            SubscriberConfig::default(),
7240        );
7241        let epoch = subscriber
7242            .stage_interest_owner(
7243                HandlerId::new("owner"),
7244                &[ReactiveInterest::Logs(LogInterest {
7245                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7246                    local_matcher: None,
7247                    route_key: None,
7248                })],
7249                SubscriberOwnerStart::Live,
7250            )
7251            .unwrap();
7252        let log = rpc_log(false);
7253
7254        subscriber.enqueue_owner_record(
7255            log_input_record(log.clone(), InputSource::Backfill),
7256            epoch.clone(),
7257        );
7258        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
7259
7260        let batch = subscriber
7261            .next_scoped_batch()
7262            .await
7263            .unwrap()
7264            .expect("owner backfill and canonical live delivery");
7265        assert_eq!(batch.records.len(), 2);
7266        assert_eq!(
7267            batch.records[0].scope,
7268            SubscriberInputScope::OwnerOnly {
7269                owners: vec![epoch]
7270            }
7271        );
7272        assert_eq!(
7273            batch.records[1].scope,
7274            SubscriberInputScope::Canonical { owners: Vec::new() },
7275            "owner replay dedupe must not suppress the global live record"
7276        );
7277    }
7278
7279    #[tokio::test(flavor = "multi_thread")]
7280    #[cfg(feature = "reactive-polling")]
7281    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
7282        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7283        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7284            provider,
7285            SubscriberMode::Polling,
7286            SubscriberConfig {
7287                max_batch_size: 2,
7288                ..SubscriberConfig::default()
7289            },
7290        );
7291        let interest = ReactiveInterest::Logs(LogInterest {
7292            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7293            local_matcher: None,
7294            route_key: None,
7295        });
7296        let epoch = subscriber
7297            .stage_interest_owner(
7298                HandlerId::new("owner"),
7299                std::slice::from_ref(&interest),
7300                SubscriberOwnerStart::Live,
7301            )
7302            .unwrap();
7303        subscriber.sources_dirty = false;
7304
7305        let mut duplicate = rpc_log(false);
7306        duplicate.transaction_hash = Some(B256::repeat_byte(1));
7307        duplicate.log_index = Some(0);
7308        let events = (0u8..10).map(|index| {
7309            let mut log = rpc_log(false);
7310            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
7311            log.log_index = Some(index as u64);
7312            SubscriberEvent::Log { source_id: 0, log }
7313        });
7314        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
7315        let mut streams = SubscriberStreams::new();
7316        streams.push(
7317            SubscriberStreamSource::PollingLog { filter },
7318            stream::iter(events).boxed(),
7319        );
7320        subscriber.state = AlloySubscriberState::Active(streams);
7321
7322        let mut polls = 0usize;
7323        let fetched_duplicate = duplicate.clone();
7324        let fetch = poll_fn(move |cx| {
7325            polls += 1;
7326            if polls > 10 {
7327                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
7328            } else {
7329                cx.waker().wake_by_ref();
7330                std::task::Poll::Pending
7331            }
7332        });
7333        let target_epochs = HashSet::from([epoch.clone()]);
7334        let fetched_duplicate = subscriber
7335            .drive_reconcile_fetch(fetch, &target_epochs)
7336            .await
7337            .unwrap();
7338        subscriber.enqueue_owner_record_for_owners_unmerged(
7339            log_input_record(fetched_duplicate, InputSource::Backfill),
7340            vec![epoch.clone()],
7341        );
7342        subscriber.promote_reconcile_owner_records(&target_epochs);
7343
7344        assert_eq!(subscriber.pending_records.len(), 20);
7345        assert!(subscriber.pending_records.iter().take(10).all(|record| {
7346            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
7347        }));
7348        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
7349            record.scope
7350                == SubscriberInputScope::OwnerOnly {
7351                    owners: vec![epoch.clone()],
7352                }
7353        }));
7354    }
7355
7356    #[tokio::test(flavor = "multi_thread")]
7357    #[cfg(feature = "reactive-polling")]
7358    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
7359        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7360        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7361            provider,
7362            SubscriberMode::Polling,
7363            SubscriberConfig::default(),
7364        );
7365        subscriber.sources_dirty = false;
7366        let mut first_poll = true;
7367        let fetch = poll_fn(move |cx| {
7368            if first_poll {
7369                first_poll = false;
7370                cx.waker().wake_by_ref();
7371                std::task::Poll::Pending
7372            } else {
7373                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
7374            }
7375        });
7376
7377        let result = subscriber
7378            .drive_reconcile_fetch(fetch, &HashSet::new())
7379            .await
7380            .expect("an empty live topology must not be mistaken for termination");
7381        assert_eq!(result, "certified");
7382    }
7383
7384    #[tokio::test(flavor = "multi_thread")]
7385    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
7386    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
7387        use alloy_rpc_types_eth::{Block, Header};
7388
7389        let asserter = Asserter::new();
7390        let baseline = BlockRef {
7391            number: 100,
7392            hash: B256::repeat_byte(0x64),
7393            parent_hash: Some(B256::repeat_byte(0x63)),
7394            timestamp: Some(1_700_000_100),
7395        };
7396        let through = BlockRef {
7397            number: 101,
7398            hash: B256::repeat_byte(0x65),
7399            parent_hash: Some(baseline.hash),
7400            timestamp: Some(1_700_000_101),
7401        };
7402        let rpc_block = || -> Block {
7403            Block::empty(Header {
7404                hash: through.hash,
7405                inner: alloy_consensus::Header {
7406                    number: through.number,
7407                    parent_hash: through.parent_hash.unwrap(),
7408                    timestamp: through.timestamp.unwrap(),
7409                    ..Default::default()
7410                },
7411                total_difficulty: None,
7412                size: None,
7413            })
7414        };
7415        asserter.push_success(&Some(rpc_block()));
7416        asserter.push_success(&Vec::<Log>::new());
7417        asserter.push_success(&Some(rpc_block()));
7418        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
7419        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7420            provider,
7421            SubscriberMode::PubSub,
7422            SubscriberConfig::default(),
7423        );
7424        let interest = ReactiveInterest::Logs(LogInterest {
7425            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
7426            local_matcher: None,
7427            route_key: None,
7428        });
7429        let epoch = subscriber
7430            .stage_interest_owner(
7431                HandlerId::new("reconnect-anchor"),
7432                std::slice::from_ref(&interest),
7433                SubscriberOwnerStart::PostBlock(baseline),
7434            )
7435            .unwrap();
7436        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
7437        let source = SubscriberStreamSource::PubSubLog {
7438            id: subscriber.log_source_id(&filter),
7439            filter: filter.clone(),
7440        };
7441        let mut streams = SubscriberStreams::new();
7442        streams.push(source, stream::pending().boxed());
7443        subscriber.state = AlloySubscriberState::Active(streams);
7444        subscriber.sources_dirty = false;
7445
7446        subscriber
7447            .reconcile_interest_owner(&epoch, through.clone())
7448            .await
7449            .unwrap();
7450        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
7451        assert!(asserter.read_q().is_empty());
7452    }
7453
7454    #[tokio::test(flavor = "multi_thread")]
7455    #[cfg(feature = "reactive-polling")]
7456    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
7457        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7458        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7459            provider,
7460            SubscriberMode::Polling,
7461            SubscriberConfig::default(),
7462        );
7463        let interest = ReactiveInterest::Logs(LogInterest {
7464            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7465            local_matcher: None,
7466            route_key: None,
7467        });
7468        let epoch = subscriber
7469            .stage_interest_owner(
7470                HandlerId::new("owner"),
7471                std::slice::from_ref(&interest),
7472                SubscriberOwnerStart::PostBlock(BlockRef {
7473                    number: 100,
7474                    hash: B256::repeat_byte(0x64),
7475                    parent_hash: None,
7476                    timestamp: None,
7477                }),
7478            )
7479            .unwrap();
7480        subscriber.sources_dirty = false;
7481
7482        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
7483        let event = SubscriberEvent::Log {
7484            source_id: 0,
7485            log: rpc_log(false),
7486        };
7487        let mut streams = SubscriberStreams::new();
7488        streams.push(
7489            SubscriberStreamSource::PollingLog { filter },
7490            stream::once(async move { event })
7491                .chain(stream::pending())
7492                .boxed(),
7493        );
7494        subscriber.state = AlloySubscriberState::Active(streams);
7495
7496        let targets = HashSet::from([epoch.clone()]);
7497        {
7498            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
7499            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
7500            futures::pin_mut!(drive);
7501            poll_fn(|cx| {
7502                assert!(drive.as_mut().poll(cx).is_pending());
7503                std::task::Poll::Ready(())
7504            })
7505            .await;
7506        }
7507
7508        assert_eq!(subscriber.pending_records.len(), 1);
7509        assert_eq!(
7510            subscriber.pending_records[0].scope,
7511            SubscriberInputScope::Canonical { owners: Vec::new() },
7512            "canonical delivery commits immediately at a cancellation-safe boundary"
7513        );
7514        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
7515
7516        subscriber
7517            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
7518            .await
7519            .unwrap();
7520        subscriber.promote_reconcile_owner_records(&targets);
7521        assert!(subscriber.pending_reconcile_owner_records.is_empty());
7522        assert_eq!(subscriber.pending_records.len(), 2);
7523        assert_eq!(
7524            subscriber.pending_records[0].scope,
7525            SubscriberInputScope::Canonical { owners: Vec::new() },
7526            "canonical delivery remains target-excluded"
7527        );
7528        assert_eq!(
7529            subscriber.pending_records[1].scope,
7530            SubscriberInputScope::OwnerOnly {
7531                owners: vec![epoch]
7532            },
7533            "retry commit appends hidden owner delivery after historical catch-up"
7534        );
7535    }
7536
7537    #[tokio::test(flavor = "multi_thread")]
7538    #[cfg(feature = "reactive-ws")]
7539    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
7540        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7541        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7542            provider,
7543            SubscriberMode::PubSub,
7544            SubscriberConfig {
7545                reconnect: SubscriberReconnectConfig {
7546                    initial_delay: Duration::from_secs(60),
7547                    ..SubscriberReconnectConfig::default()
7548                },
7549                ..SubscriberConfig::default()
7550            },
7551        );
7552        let interest = ReactiveInterest::Logs(LogInterest {
7553            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
7554            local_matcher: None,
7555            route_key: None,
7556        });
7557        let epoch = subscriber
7558            .stage_interest_owner(
7559                HandlerId::new("owner"),
7560                std::slice::from_ref(&interest),
7561                SubscriberOwnerStart::PostBlock(BlockRef {
7562                    number: 7,
7563                    hash: B256::repeat_byte(0x07),
7564                    parent_hash: Some(B256::repeat_byte(0x06)),
7565                    timestamp: Some(1_700_000_007),
7566                }),
7567            )
7568            .unwrap();
7569        subscriber.sources_dirty = false;
7570        subscriber.stream_revision = 1;
7571        let entry = subscriber
7572            .owned_interests
7573            .iter_mut()
7574            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
7575            .unwrap();
7576        entry.progress = Some(SubscriberOwnerProgress {
7577            owner: epoch.clone(),
7578            through: entry.baseline.clone().unwrap(),
7579        });
7580        entry.progress_stream_revision = Some(1);
7581
7582        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
7583        let source = SubscriberStreamSource::PubSubLog {
7584            id: subscriber.log_source_id(&filter),
7585            filter,
7586        };
7587        let mut streams = SubscriberStreams::new();
7588        streams.push(
7589            source.clone(),
7590            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
7591        );
7592        subscriber.state = AlloySubscriberState::Active(streams);
7593        let prior_revision = subscriber.stream_revision;
7594
7595        let mut first_poll = true;
7596        let control = poll_fn(move |cx| {
7597            if first_poll {
7598                first_poll = false;
7599                cx.waker().wake_by_ref();
7600                std::task::Poll::Pending
7601            } else {
7602                std::task::Poll::Ready("stop")
7603            }
7604        });
7605        futures::pin_mut!(control);
7606        let outcome = subscriber
7607            .next_scoped_batch_or(control.as_mut())
7608            .await
7609            .unwrap();
7610
7611        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
7612        assert!(subscriber.sources_dirty);
7613        assert!(subscriber.stream_revision > prior_revision);
7614        assert!(
7615            !subscriber.activate_interest_owner(&epoch),
7616            "progress certified against the terminated stream revision is stale"
7617        );
7618    }
7619
7620    #[test]
7621    #[cfg(feature = "reactive-ws")]
7622    fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
7623        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7624        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7625            provider,
7626            SubscriberMode::PubSub,
7627            SubscriberConfig::default(),
7628        );
7629        subscriber
7630            .register_interests(&[
7631                ReactiveInterest::Logs(LogInterest {
7632                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
7633                    local_matcher: None,
7634                    route_key: None,
7635                }),
7636                ReactiveInterest::Logs(LogInterest {
7637                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
7638                    local_matcher: None,
7639                    route_key: None,
7640                }),
7641                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
7642            ])
7643            .expect("register base interests");
7644
7645        // The two default-block-option log filters merge into one address
7646        // superset (existing consolidation behavior), so there is one log source
7647        // — assigned id 0, before the pending-hash source.
7648        let sources = subscriber.stream_sources().expect("stream sources");
7649        assert_eq!(sources.len(), 2);
7650        assert!(matches!(
7651            &sources[0],
7652            SubscriberStreamSource::PubSubLog { id: 0, .. }
7653        ));
7654        assert!(matches!(
7655            sources[1],
7656            SubscriberStreamSource::PubSubPendingHashes
7657        ));
7658
7659        // Ids are stable across repeated source construction.
7660        let again = subscriber.stream_sources().expect("stream sources again");
7661        assert!(again[0].same_key(&sources[0]));
7662    }
7663
7664    #[tokio::test(flavor = "multi_thread")]
7665    #[cfg(feature = "reactive-ws")]
7666    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
7667        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7668        let mut subscriber = AlloySubscriber::new(
7669            provider,
7670            SubscriberMode::PubSub,
7671            SubscriberConfig {
7672                reconnect: SubscriberReconnectConfig {
7673                    initial_delay: Duration::ZERO,
7674                    retry_delay: Duration::ZERO,
7675                    max_delay: Duration::ZERO,
7676                    max_attempts: Some(1),
7677                    ..SubscriberReconnectConfig::default()
7678                },
7679                ..SubscriberConfig::default()
7680            },
7681        );
7682        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
7683            PendingTxInterest::default(),
7684        )];
7685
7686        let mut streams = SubscriberStreams::new();
7687        let source = SubscriberStreamSource::PubSubPendingHashes;
7688        streams.push(
7689            source,
7690            stream::once(async {
7691                SubscriberEvent::<Ethereum>::StreamTerminated(
7692                    SubscriberStreamSource::PubSubPendingHashes,
7693                )
7694            })
7695            .boxed(),
7696        );
7697        subscriber.state = AlloySubscriberState::Active(streams);
7698
7699        let result = subscriber.next_batch().await;
7700        assert!(
7701            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
7702            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
7703        );
7704    }
7705
7706    #[test]
7707    fn backfilled_logs_skip_recent_subscription_duplicates() {
7708        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7709        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7710            provider,
7711            SubscriberMode::PubSub,
7712            SubscriberConfig::default(),
7713        );
7714        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
7715            provider_filter: Filter::new()
7716                .address(Address::repeat_byte(0x42))
7717                .event_signature(B256::repeat_byte(0x01)),
7718            local_matcher: None,
7719            route_key: None,
7720        })];
7721
7722        let log = rpc_log(false);
7723        subscriber.enqueue_event(SubscriberEvent::Log {
7724            source_id: 0,
7725            log: log.clone(),
7726        });
7727        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
7728            source_id: 0,
7729            logs: vec![log],
7730        });
7731
7732        assert_eq!(subscriber.pending_records.len(), 1);
7733        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
7734        assert_eq!(
7735            subscriber.pending_records[0].context.source,
7736            InputSource::Subscription
7737        );
7738    }
7739
7740    #[test]
7741    fn backfilled_logs_surface_with_backfill_source() {
7742        // A backfilled log with no prior subscription duplicate is delivered as
7743        // an `InputSource::Backfill` record (the positive side of the dedup test,
7744        // pinning the README's "marking recovered records as InputSource::Backfill"
7745        // claim — the only place that source is produced).
7746        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7747        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7748            provider,
7749            SubscriberMode::PubSub,
7750            SubscriberConfig::default(),
7751        );
7752        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
7753            provider_filter: Filter::new()
7754                .address(Address::repeat_byte(0x42))
7755                .event_signature(B256::repeat_byte(0x01)),
7756            local_matcher: None,
7757            route_key: None,
7758        })];
7759
7760        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
7761            source_id: 0,
7762            logs: vec![rpc_log(false)],
7763        });
7764
7765        assert_eq!(subscriber.pending_records.len(), 1);
7766        assert_eq!(
7767            subscriber.pending_records[0].context.source,
7768            InputSource::Backfill
7769        );
7770        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
7771    }
7772
7773    #[test]
7774    #[cfg(feature = "reactive-ws")]
7775    fn owner_removal_preserves_delivery_and_dedupe_state() {
7776        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7777        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7778            provider,
7779            SubscriberMode::PubSub,
7780            SubscriberConfig::default(),
7781        );
7782        subscriber
7783            .add_interest_owner(
7784                HandlerId::new("pool-a"),
7785                &[ReactiveInterest::Logs(LogInterest {
7786                    provider_filter: Filter::new()
7787                        .address(Address::repeat_byte(0x42))
7788                        .event_signature(B256::repeat_byte(0x01)),
7789                    local_matcher: None,
7790                    route_key: None,
7791                })],
7792            )
7793            .expect("register pool-a owner");
7794        subscriber
7795            .add_interest_owner(
7796                HandlerId::new("pool-b"),
7797                &[ReactiveInterest::Logs(LogInterest {
7798                    provider_filter: Filter::new()
7799                        .address(Address::repeat_byte(0x24))
7800                        .event_signature(B256::repeat_byte(0x02)),
7801                    local_matcher: None,
7802                    route_key: None,
7803                })],
7804            )
7805            .expect("register pool-b owner");
7806
7807        // Allocate source ids the way live stream setup would (pool-a -> id 0),
7808        // so the injected delivery anchor hangs off a referenced filter.
7809        let sources = subscriber.stream_sources().expect("stream sources");
7810        subscriber.enqueue_event(SubscriberEvent::Log {
7811            source_id: 0,
7812            log: rpc_log(false),
7813        });
7814        let mut streams = SubscriberStreams::new();
7815        streams.push(
7816            sources[0].clone(),
7817            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
7818        );
7819        subscriber.state = AlloySubscriberState::Active(streams);
7820        assert_eq!(subscriber.pending_records.len(), 1);
7821        assert_eq!(subscriber.recent_input_refs.len(), 1);
7822        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
7823
7824        let removed = subscriber
7825            .remove_interest_owner(&HandlerId::new("pool-b"))
7826            .expect("pool-b should be removed");
7827
7828        assert_eq!(removed.len(), 1);
7829        assert_eq!(subscriber.pending_records.len(), 1);
7830        assert_eq!(subscriber.recent_input_refs.len(), 1);
7831        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
7832        assert!(
7833            subscriber
7834                .owner_interests(&HandlerId::new("pool-a"))
7835                .is_some()
7836        );
7837        assert!(
7838            subscriber
7839                .owner_interests(&HandlerId::new("pool-b"))
7840                .is_none()
7841        );
7842        assert_eq!(subscriber.registered_interests().len(), 1);
7843    }
7844
7845    #[test]
7846    #[cfg(feature = "reactive-ws")]
7847    fn owner_log_sources_fan_in_across_owners() {
7848        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7849        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7850            provider,
7851            SubscriberMode::PubSub,
7852            SubscriberConfig::default(),
7853        );
7854        subscriber
7855            .add_interest_owner(
7856                HandlerId::new("pool-a"),
7857                &[ReactiveInterest::Logs(LogInterest {
7858                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
7859                    local_matcher: None,
7860                    route_key: None,
7861                })],
7862            )
7863            .expect("register pool-a owner");
7864
7865        let initial_sources = subscriber.stream_sources().expect("initial sources");
7866        assert_eq!(initial_sources.len(), 1);
7867        let pool_a_source = initial_sources[0].clone();
7868        assert!(matches!(
7869            &pool_a_source,
7870            SubscriberStreamSource::PubSubLog { id: 0, .. }
7871        ));
7872
7873        subscriber
7874            .add_interest_owner(
7875                HandlerId::new("pool-b"),
7876                &[ReactiveInterest::Logs(LogInterest {
7877                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
7878                    local_matcher: None,
7879                    route_key: None,
7880                })],
7881            )
7882            .expect("register pool-b owner");
7883
7884        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
7885        assert_eq!(
7886            expanded_sources.len(),
7887            1,
7888            "compatible owner filters should share one provider subscription"
7889        );
7890        assert!(
7891            !expanded_sources[0].same_key(&pool_a_source),
7892            "the provider-facing superset changes while owner routing remains exact"
7893        );
7894
7895        subscriber
7896            .remove_interest_owner(&HandlerId::new("pool-b"))
7897            .expect("pool-b should be removed");
7898        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
7899        assert_eq!(trimmed_sources.len(), 1);
7900        assert!(trimmed_sources[0].same_key(&pool_a_source));
7901    }
7902
7903    #[test]
7904    #[cfg(feature = "reactive-ws")]
7905    fn provider_log_fan_in_respects_address_ceiling() {
7906        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
7907        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7908            provider,
7909            SubscriberMode::PubSub,
7910            SubscriberConfig {
7911                max_log_addresses_per_subscription: 2,
7912                ..SubscriberConfig::default()
7913            },
7914        );
7915        for index in 0..5 {
7916            subscriber
7917                .add_interest_owner(
7918                    HandlerId::new(format!("pool-{index}")),
7919                    &[log_interest_for(index + 1)],
7920                )
7921                .expect("register pool owner");
7922        }
7923
7924        let sources = subscriber.stream_sources().expect("stream sources");
7925        assert_eq!(sources.len(), 3);
7926        let mut address_counts: Vec<_> = sources
7927            .iter()
7928            .map(|source| match source {
7929                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
7930                _ => panic!("expected log source"),
7931            })
7932            .collect();
7933        address_counts.sort_unstable();
7934        assert_eq!(address_counts, vec![1, 2, 2]);
7935    }
7936
7937    #[tokio::test(flavor = "multi_thread")]
7938    #[cfg(feature = "reactive-ws")]
7939    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
7940        let asserter = Asserter::new();
7941        asserter.push_success(&vec![rpc_log(false)]);
7942        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
7943        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
7944            provider,
7945            SubscriberMode::PubSub,
7946            SubscriberConfig::default(),
7947        );
7948        subscriber
7949            .add_interest_owner_with_backfill(
7950                HandlerId::new("pool-a"),
7951                &[ReactiveInterest::Logs(LogInterest {
7952                    provider_filter: Filter::new()
7953                        .address(Address::repeat_byte(0x42))
7954                        .event_signature(B256::repeat_byte(0x01)),
7955                    local_matcher: None,
7956                    route_key: None,
7957                })],
7958                SubscriberBackfill::range(1, 7),
7959            )
7960            .expect("register pool-a with backfill");
7961
7962        subscriber
7963            .drain_pending_backfills()
7964            .await
7965            .expect("owner backfill should drain");
7966
7967        assert_eq!(subscriber.pending_records.len(), 1);
7968        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
7969    }
7970
7971    #[tokio::test(flavor = "multi_thread")]
7972    async fn subscriber_streams_poll_ready_sources_round_robin() {
7973        let first_hash = B256::repeat_byte(0x01);
7974        let second_hash = B256::repeat_byte(0x02);
7975        let mut streams = SubscriberStreams::new();
7976        streams.push(
7977            SubscriberStreamSource::PubSubPendingHashes,
7978            stream::iter([
7979                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
7980                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
7981            ])
7982            .boxed(),
7983        );
7984        streams.push(
7985            SubscriberStreamSource::PubSubBlockHeaders,
7986            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
7987                .boxed(),
7988        );
7989
7990        assert!(matches!(
7991            streams.next().await,
7992            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
7993        ));
7994        assert!(matches!(
7995            streams.next().await,
7996            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
7997        ));
7998    }
7999
8000    #[tokio::test(flavor = "multi_thread")]
8001    #[cfg(feature = "reactive-ws")]
8002    async fn owner_updates_ensure_streams_without_full_reset() {
8003        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8004        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8005            provider,
8006            SubscriberMode::PubSub,
8007            SubscriberConfig::default(),
8008        );
8009        subscriber
8010            .register_interests(&[ReactiveInterest::PendingTransactions(
8011                PendingTxInterest::default(),
8012            )])
8013            .expect("register base pending interest");
8014        subscriber
8015            .add_interest_owner(
8016                HandlerId::new("headers"),
8017                &[ReactiveInterest::Blocks(BlockInterest::default())],
8018            )
8019            .expect("register header owner");
8020
8021        let mut streams = SubscriberStreams::new();
8022        streams.push(
8023            SubscriberStreamSource::PubSubPendingHashes,
8024            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
8025        );
8026        streams.push(
8027            SubscriberStreamSource::PubSubBlockHeaders,
8028            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
8029        );
8030        subscriber.state = AlloySubscriberState::Active(streams);
8031
8032        subscriber
8033            .remove_interest_owner(&HandlerId::new("headers"))
8034            .expect("header owner should be removed");
8035        assert!(matches!(
8036            &subscriber.state,
8037            AlloySubscriberState::Active(streams) if streams.len() == 2
8038        ));
8039
8040        subscriber
8041            .ensure_streams()
8042            .await
8043            .expect("pure removal reconciliation should not touch provider");
8044
8045        assert!(matches!(
8046            &subscriber.state,
8047            AlloySubscriberState::Active(streams)
8048                if streams.len() == 1
8049                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
8050                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
8051        ));
8052
8053        subscriber
8054            .add_interest_owner(
8055                HandlerId::new("headers"),
8056                &[ReactiveInterest::Blocks(BlockInterest::default())],
8057            )
8058            .expect("re-add header owner");
8059        assert!(matches!(
8060            &subscriber.state,
8061            AlloySubscriberState::Active(streams) if streams.len() == 1
8062        ));
8063    }
8064
8065    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
8066    #[cfg(feature = "reactive-ws")]
8067    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
8068        ReactiveInterest::Logs(LogInterest {
8069            provider_filter: Filter::new()
8070                .address(Address::repeat_byte(0x42))
8071                .event_signature(B256::repeat_byte(0x01)),
8072            local_matcher: None,
8073            route_key: None,
8074        })
8075    }
8076
8077    #[cfg(feature = "reactive-ws")]
8078    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
8079        ReactiveInterest::Logs(LogInterest {
8080            provider_filter: Filter::new().address(Address::repeat_byte(address)),
8081            local_matcher: None,
8082            route_key: None,
8083        })
8084    }
8085
8086    // B1: a transient provider error must not consume the queued backfill — the
8087    // missed window has to survive for the next poll to retry.
8088    #[tokio::test(flavor = "multi_thread")]
8089    #[cfg(feature = "reactive-ws")]
8090    async fn drain_backfill_retains_queue_entry_on_provider_error() {
8091        let asserter = Asserter::new();
8092        asserter.push_failure_msg("rate limited");
8093        asserter.push_success(&vec![rpc_log(false)]);
8094        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
8095        let mut subscriber = AlloySubscriber::new(
8096            provider,
8097            SubscriberMode::PubSub,
8098            SubscriberConfig::default(),
8099        );
8100        subscriber
8101            .add_interest_owner_with_backfill(
8102                HandlerId::new("pool"),
8103                &[log_interest_matching_rpc_log()],
8104                SubscriberBackfill::range(1, 7),
8105            )
8106            .expect("register owner with backfill");
8107        assert_eq!(subscriber.pending_backfills.len(), 1);
8108
8109        let first = subscriber.drain_pending_backfills().await;
8110        assert!(first.is_err(), "provider failure should surface");
8111        assert_eq!(
8112            subscriber.pending_backfills.len(),
8113            1,
8114            "failed fetch must leave the backfill queued for retry"
8115        );
8116        assert!(subscriber.pending_records.is_empty());
8117
8118        subscriber
8119            .drain_pending_backfills()
8120            .await
8121            .expect("retry should succeed");
8122        assert!(subscriber.pending_backfills.is_empty());
8123        assert_eq!(subscriber.pending_records.len(), 1);
8124    }
8125
8126    // B3: a zero-log backfill window still advances the delivery anchor to its
8127    // upper bound, so a later reconnect catches up from the right block.
8128    #[tokio::test(flavor = "multi_thread")]
8129    #[cfg(feature = "reactive-ws")]
8130    async fn drain_backfill_seeds_anchor_on_empty_window() {
8131        let asserter = Asserter::new();
8132        asserter.push_success(&Vec::<Log>::new());
8133        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
8134        let mut subscriber = AlloySubscriber::new(
8135            provider,
8136            SubscriberMode::PubSub,
8137            SubscriberConfig::default(),
8138        );
8139        subscriber
8140            .add_interest_owner_with_backfill(
8141                HandlerId::new("pool"),
8142                &[log_interest_matching_rpc_log()],
8143                SubscriberBackfill::range(1, 42),
8144            )
8145            .expect("register owner with backfill");
8146
8147        subscriber
8148            .drain_pending_backfills()
8149            .await
8150            .expect("empty backfill should drain");
8151
8152        assert!(subscriber.pending_records.is_empty());
8153        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
8154            .pop()
8155            .unwrap();
8156        assert_eq!(
8157            subscriber.log_anchor(&filter),
8158            Some(42),
8159            "empty window must still seed the anchor at its upper bound"
8160        );
8161    }
8162
8163    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
8164    // the provider head and seeds the anchor there.
8165    #[tokio::test(flavor = "multi_thread")]
8166    #[cfg(feature = "reactive-ws")]
8167    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
8168        let asserter = Asserter::new();
8169        asserter.push_success(&100u64); // get_block_number
8170        asserter.push_success(&Vec::<Log>::new()); // get_logs
8171        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
8172        let mut subscriber = AlloySubscriber::new(
8173            provider,
8174            SubscriberMode::PubSub,
8175            SubscriberConfig::default(),
8176        );
8177        subscriber
8178            .add_interest_owner_with_backfill(
8179                HandlerId::new("pool"),
8180                &[log_interest_matching_rpc_log()],
8181                SubscriberBackfill::from_block(10),
8182            )
8183            .expect("register owner with open-ended backfill");
8184
8185        subscriber
8186            .drain_pending_backfills()
8187            .await
8188            .expect("open-ended backfill should drain");
8189
8190        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
8191            .pop()
8192            .unwrap();
8193        assert_eq!(subscriber.log_anchor(&filter), Some(100));
8194    }
8195
8196    // B2: two owners requesting the same filter shape share exactly one live
8197    // source (and thus one anchor), rather than double-subscribing.
8198    #[test]
8199    #[cfg(feature = "reactive-ws")]
8200    fn duplicate_filters_across_owners_map_to_single_source() {
8201        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8202        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8203            provider,
8204            SubscriberMode::PubSub,
8205            SubscriberConfig::default(),
8206        );
8207        subscriber
8208            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
8209            .expect("register pool-a");
8210        subscriber
8211            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
8212            .expect("register pool-b with identical filter");
8213
8214        assert_eq!(
8215            subscriber.log_stream_filters().len(),
8216            1,
8217            "identical filters across owners must collapse to one"
8218        );
8219        let sources = subscriber.stream_sources().expect("stream sources");
8220        assert_eq!(sources.len(), 1);
8221    }
8222
8223    // B4: removing an owner retires the source-id and anchor bookkeeping for
8224    // filters no other owner references, so long-lived churn cannot leak.
8225    #[test]
8226    #[cfg(feature = "reactive-ws")]
8227    fn owner_removal_prunes_source_ids_and_anchors() {
8228        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8229        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8230            provider,
8231            SubscriberMode::PubSub,
8232            SubscriberConfig::default(),
8233        );
8234        subscriber
8235            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
8236            .expect("register pool-a");
8237        subscriber
8238            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
8239            .expect("register pool-b");
8240
8241        // Allocate ids and simulate delivery anchors on both.
8242        let _ = subscriber.stream_sources().expect("stream sources");
8243        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
8244        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
8245        let id_a = subscriber.log_source_id(&filter_a);
8246        let id_b = subscriber.log_source_id(&filter_b);
8247        subscriber.last_seen_log_blocks.insert(id_a, 10);
8248        subscriber.last_seen_log_blocks.insert(id_b, 20);
8249        assert_eq!(
8250            subscriber.log_source_ids.len(),
8251            3,
8252            "one provider fan-in id plus two explicitly seeded logical ids"
8253        );
8254
8255        subscriber
8256            .remove_interest_owner(&HandlerId::new("pool-b"))
8257            .expect("remove pool-b");
8258
8259        assert_eq!(
8260            subscriber.log_source_ids.len(),
8261            1,
8262            "pool-b's filter id should be retired"
8263        );
8264        assert!(subscriber.log_source_ids.contains_key(&filter_a));
8265        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
8266        assert_eq!(
8267            subscriber.last_seen_log_blocks.get(&id_b),
8268            None,
8269            "pool-b's anchor should be pruned"
8270        );
8271    }
8272
8273    // D1: growing an owner's filter set (a new pool on an existing adapter)
8274    // changes the merged filter shape; the new shape must inherit the old
8275    // anchor via an automatic continuity backfill, or logs between the last
8276    // delivery and the new subscription are silently lost.
8277    #[test]
8278    #[cfg(feature = "reactive-ws")]
8279    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
8280        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8281        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8282            provider,
8283            SubscriberMode::PubSub,
8284            SubscriberConfig::default(),
8285        );
8286        subscriber
8287            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
8288            .expect("register amm with pool A");
8289
8290        // Simulate the owner's single merged filter having delivered up to
8291        // block 50.
8292        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
8293        let id_a = subscriber.log_source_id(&filter_a);
8294        subscriber.last_seen_log_blocks.insert(id_a, 50);
8295
8296        // Grow the owner to also watch pool B (same block option -> merges into
8297        // one {A,B} filter, a new shape).
8298        subscriber
8299            .add_interest_owner(
8300                HandlerId::new("amm"),
8301                &[log_interest_for(0xaa), log_interest_for(0xbb)],
8302            )
8303            .expect("grow amm to pools A+B");
8304
8305        assert_eq!(
8306            subscriber.pending_backfills.len(),
8307            1,
8308            "the changed merged filter should queue exactly one continuity backfill"
8309        );
8310        let queued = &subscriber.pending_backfills[0];
8311        assert_eq!(queued.owner, HandlerId::new("amm"));
8312        assert_eq!(queued.backfill.start_block(), 50);
8313        assert_eq!(
8314            queued.backfill.end_block(),
8315            None,
8316            "continuity backfill runs open-ended to the current head"
8317        );
8318    }
8319
8320    // D1 negative: replacing an owner's interests with the identical shape must
8321    // NOT re-fetch — the filter kept its anchor and its live stream.
8322    #[test]
8323    #[cfg(feature = "reactive-ws")]
8324    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
8325        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8326        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8327            provider,
8328            SubscriberMode::PubSub,
8329            SubscriberConfig::default(),
8330        );
8331        subscriber
8332            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
8333            .expect("register amm");
8334        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
8335        let id_a = subscriber.log_source_id(&filter_a);
8336        subscriber.last_seen_log_blocks.insert(id_a, 50);
8337
8338        subscriber
8339            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
8340            .expect("re-register identical interests");
8341
8342        assert!(
8343            subscriber.pending_backfills.is_empty(),
8344            "an unchanged filter shape must not queue continuity backfill"
8345        );
8346    }
8347
8348    // D5 interaction: an explicit open-ended backfill starting at or below the
8349    // owner's prior anchor already covers the continuity window, so no extra
8350    // continuity backfill is queued (no redundant double fetch).
8351    #[test]
8352    #[cfg(feature = "reactive-ws")]
8353    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
8354        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8355        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8356            provider,
8357            SubscriberMode::PubSub,
8358            SubscriberConfig::default(),
8359        );
8360        subscriber
8361            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
8362            .expect("register amm");
8363        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
8364        let id_a = subscriber.log_source_id(&filter_a);
8365        subscriber.last_seen_log_blocks.insert(id_a, 50);
8366
8367        // Grow with an explicit deep backfill from block 10 (< anchor 50).
8368        subscriber
8369            .add_interest_owner_with_backfill(
8370                HandlerId::new("amm"),
8371                &[log_interest_for(0xaa), log_interest_for(0xbb)],
8372                SubscriberBackfill::from_block(10),
8373            )
8374            .expect("grow amm with explicit deep backfill");
8375
8376        assert_eq!(
8377            subscriber.pending_backfills.len(),
8378            1,
8379            "only the explicit backfill should be queued; continuity is subsumed"
8380        );
8381        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
8382    }
8383
8384    // The dirty flag gates reconciliation: when nothing changed since the last
8385    // reconcile, `ensure_streams` must not touch the provider or the state.
8386    #[tokio::test(flavor = "multi_thread")]
8387    #[cfg(feature = "reactive-ws")]
8388    async fn ensure_streams_is_noop_when_not_dirty() {
8389        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
8390        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
8391            provider,
8392            SubscriberMode::PubSub,
8393            SubscriberConfig::default(),
8394        );
8395        // An interest that WOULD require a new block-header source...
8396        subscriber
8397            .add_interest_owner(
8398                HandlerId::new("headers"),
8399                &[ReactiveInterest::Blocks(BlockInterest::default())],
8400            )
8401            .expect("register header owner");
8402        // ...but we mark bookkeeping clean and start from Empty.
8403        subscriber.state = AlloySubscriberState::Empty;
8404        subscriber.sources_dirty = false;
8405
8406        subscriber
8407            .ensure_streams()
8408            .await
8409            .expect("clean reconcile must be a no-op");
8410
8411        assert!(
8412            matches!(subscriber.state, AlloySubscriberState::Empty),
8413            "not-dirty ensure_streams must not connect new sources"
8414        );
8415    }
8416}
8417
8418fn resolve_subscriber_transport(
8419    mode: SubscriberMode,
8420) -> Result<SubscriberTransport, SubscriberError> {
8421    match mode {
8422        SubscriberMode::PubSub => {
8423            #[cfg(feature = "reactive-ws")]
8424            {
8425                Ok(SubscriberTransport::PubSub)
8426            }
8427            #[cfg(not(feature = "reactive-ws"))]
8428            {
8429                Err(SubscriberError::Unsupported(
8430                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
8431                ))
8432            }
8433        }
8434        SubscriberMode::Polling => {
8435            #[cfg(feature = "reactive-polling")]
8436            {
8437                Ok(SubscriberTransport::Polling)
8438            }
8439            #[cfg(not(feature = "reactive-polling"))]
8440            {
8441                Err(SubscriberError::Unsupported(
8442                    "AlloySubscriber polling mode requires the reactive-polling feature",
8443                ))
8444            }
8445        }
8446        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
8447    }
8448}
8449
8450fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
8451    #[cfg(feature = "reactive-ws")]
8452    {
8453        Ok(SubscriberTransport::PubSub)
8454    }
8455
8456    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
8457    {
8458        Ok(SubscriberTransport::Polling)
8459    }
8460
8461    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
8462    {
8463        Err(SubscriberError::Unsupported(
8464            "AlloySubscriber requires either reactive-ws or reactive-polling",
8465        ))
8466    }
8467}
8468
8469fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
8470    if config.max_batch_size == 0 {
8471        return Err(SubscriberError::InvalidConfig(
8472            "SubscriberConfig::max_batch_size must be greater than zero",
8473        ));
8474    }
8475    if config.max_log_addresses_per_subscription == 0 {
8476        return Err(SubscriberError::InvalidConfig(
8477            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
8478        ));
8479    }
8480    if config.reconnect.enabled {
8481        if config.reconnect.retry_delay > config.reconnect.max_delay {
8482            return Err(SubscriberError::InvalidConfig(
8483                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
8484            ));
8485        }
8486        if matches!(config.reconnect.max_attempts, Some(0)) {
8487            return Err(SubscriberError::InvalidConfig(
8488                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
8489            ));
8490        }
8491    }
8492    Ok(())
8493}
8494
8495fn validate_supported_interests<N: Network>(
8496    mode: SubscriberMode,
8497    config: &SubscriberConfig,
8498    interests: &[ReactiveInterest<N>],
8499) -> Result<(), SubscriberError> {
8500    let transport = resolve_subscriber_transport(mode)?;
8501
8502    for interest in interests {
8503        match interest {
8504            ReactiveInterest::Logs(_) => {}
8505            ReactiveInterest::PendingTransactions(interest)
8506                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
8507            ReactiveInterest::PendingTransactions(_) => {
8508                return Err(SubscriberError::Unsupported(
8509                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
8510                ));
8511            }
8512            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
8513                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
8514                (_, BlockInterestMode::FullBlock) => {
8515                    return Err(SubscriberError::Unsupported(
8516                        "AlloySubscriber full block streams are not implemented in this transport slice",
8517                    ));
8518                }
8519                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
8520                    return Err(SubscriberError::Unsupported(
8521                        "AlloySubscriber polling block streams are not implemented in this transport slice",
8522                    ));
8523                }
8524            },
8525        }
8526    }
8527
8528    Ok(())
8529}
8530
8531fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
8532    let mut filters = Vec::new();
8533    for interest in interests {
8534        if let ReactiveInterest::Logs(interest) = interest {
8535            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
8536        }
8537    }
8538    filters
8539}
8540
8541fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
8542    interests.iter().any(|interest| {
8543        matches!(
8544            interest,
8545            ReactiveInterest::Blocks(BlockInterest {
8546                mode: BlockInterestMode::Header,
8547            })
8548        )
8549    })
8550}
8551
8552fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
8553    interests.iter().any(|interest| {
8554        matches!(
8555            interest,
8556            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
8557        )
8558    })
8559}
8560
8561fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
8562    interests.iter().any(|interest| {
8563        matches!(
8564            interest,
8565            ReactiveInterest::Logs(interest) if interest.matches(log)
8566        )
8567    })
8568}
8569
8570fn validate_owner_backfill_logs(
8571    logs: &[Log],
8572    from_block: u64,
8573    through: &BlockRef,
8574) -> Result<(), SubscriberOwnerError> {
8575    for log in logs {
8576        if log.removed {
8577            return Err(SubscriberOwnerError::InvalidBackfillLog(
8578                "removed log in canonical catch-up",
8579            ));
8580        }
8581        let number = log
8582            .block_number
8583            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
8584                "log missing block number",
8585            ))?;
8586        let hash = log
8587            .block_hash
8588            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
8589                "log missing block hash",
8590            ))?;
8591        log.transaction_hash
8592            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
8593                "log missing transaction hash",
8594            ))?;
8595        log.transaction_index
8596            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
8597                "log missing transaction index",
8598            ))?;
8599        log.log_index
8600            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
8601                "log missing log index",
8602            ))?;
8603        if number < from_block || number > through.number {
8604            return Err(SubscriberOwnerError::InvalidBackfillLog(
8605                "log outside requested block range",
8606            ));
8607        }
8608        if number == through.number && hash != through.hash {
8609            return Err(SubscriberOwnerError::InvalidBackfillLog(
8610                "target-block log hash mismatch",
8611            ));
8612        }
8613    }
8614    Ok(())
8615}
8616
8617fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
8618    let mut positions = HashMap::new();
8619    let mut block_hashes = HashMap::new();
8620    let mut transaction_hashes = HashMap::new();
8621    let mut transaction_positions = HashMap::new();
8622    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
8623    for log in logs {
8624        let number = log
8625            .block_number
8626            .expect("individual owner catch-up logs are validated before set validation");
8627        let block_hash = log
8628            .block_hash
8629            .expect("individual owner catch-up logs are validated before set validation");
8630        let transaction_hash = log
8631            .transaction_hash
8632            .expect("individual owner catch-up logs are validated before set validation");
8633        let transaction_index = log
8634            .transaction_index
8635            .expect("individual owner catch-up logs are validated before set validation");
8636        let log_index = log
8637            .log_index
8638            .expect("individual owner catch-up logs are validated before set validation");
8639        if block_hashes
8640            .insert(number, block_hash)
8641            .is_some_and(|prior| prior != block_hash)
8642        {
8643            return Err(SubscriberOwnerError::InvalidBackfillLog(
8644                "conflicting block identity in canonical catch-up",
8645            ));
8646        }
8647        if let Some(previous) = positions.insert((number, log_index), log)
8648            && previous != log
8649        {
8650            return Err(SubscriberOwnerError::InvalidBackfillLog(
8651                "conflicting logs at one canonical block position",
8652            ));
8653        }
8654        let conflicting_transaction = transaction_hashes
8655            .insert((number, transaction_index), transaction_hash)
8656            .is_some_and(|prior| prior != transaction_hash)
8657            || transaction_positions
8658                .insert((number, transaction_hash), transaction_index)
8659                .is_some_and(|prior| prior != transaction_index);
8660        if conflicting_transaction {
8661            return Err(SubscriberOwnerError::InvalidBackfillLog(
8662                "conflicting transaction identity at one canonical block position",
8663            ));
8664        }
8665        ordering
8666            .entry(number)
8667            .or_default()
8668            .push((log_index, transaction_index));
8669    }
8670    for positions in ordering.values_mut() {
8671        positions.sort_unstable();
8672        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
8673            return Err(SubscriberOwnerError::InvalidBackfillLog(
8674                "transaction and log positions disagree on canonical order",
8675            ));
8676        }
8677    }
8678    Ok(())
8679}
8680
8681fn merged_owner_reconcile_filters<N: Network>(
8682    plans: &[SubscriberOwnerReconcilePlan<N>],
8683    through: u64,
8684) -> Vec<SubscriberOwnerReconcileFilter> {
8685    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
8686    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
8687        let filters = by_start.entry(plan.from_block).or_default();
8688        filters.extend(
8689            log_filters(&plan.interests)
8690                .into_iter()
8691                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
8692        );
8693    }
8694
8695    let mut chunks = Vec::new();
8696    for (from_block, filters) in by_start {
8697        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
8698            let mut merged = Vec::new();
8699            for filter in filters {
8700                merge_log_subscription_filter(&mut merged, filter);
8701            }
8702            chunks.extend(
8703                merged
8704                    .into_iter()
8705                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
8706            );
8707        }
8708    }
8709    chunks
8710}
8711
8712async fn fetch_owner_catchup<P, N>(
8713    provider: P,
8714    filters: Vec<SubscriberOwnerReconcileFilter>,
8715    retained: Vec<BlockRef>,
8716    through: BlockRef,
8717) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
8718where
8719    P: Provider<N> + Send + Sync,
8720    N: Network,
8721{
8722    let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
8723    let mut certified_positions = HashSet::new();
8724    for position in retained {
8725        let target_certifies_position = position == through
8726            || (position.number.checked_add(1) == Some(through.number)
8727                && through.parent_hash == Some(position.hash));
8728        if !target_certifies_position && certified_positions.insert(position.clone()) {
8729            let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
8730        }
8731    }
8732    let mut logs = Vec::new();
8733    let requests = filters.into_iter().map(|filter| {
8734        let provider = &provider;
8735        async move {
8736            let logs = provider
8737                .get_logs(&filter.filter)
8738                .await
8739                .map_err(provider_error)?;
8740            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
8741        }
8742    });
8743    for (from_block, fetched) in try_join_all(requests).await? {
8744        validate_owner_backfill_logs(&fetched, from_block, &through)?;
8745        logs.extend(fetched);
8746    }
8747    validate_owner_backfill_log_set(&logs)?;
8748    let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
8749    Ok(SubscriberOwnerCatchup { logs, certified })
8750}
8751
8752async fn verify_provider_reconcile_target<P, N>(
8753    provider: &P,
8754    expected: &BlockRef,
8755) -> Result<BlockRef, SubscriberOwnerError>
8756where
8757    P: Provider<N> + Send + Sync,
8758    N: Network,
8759{
8760    let block = provider
8761        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
8762        .await
8763        .map_err(provider_error)?
8764        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
8765    let header = block.header();
8766    let actual = BlockRef {
8767        number: header.number(),
8768        hash: header.hash(),
8769        parent_hash: Some(header.parent_hash()),
8770        timestamp: Some(header.timestamp()),
8771    };
8772    let exact_parent = expected
8773        .parent_hash
8774        .is_none_or(|parent| Some(parent) == actual.parent_hash);
8775    let exact_timestamp = expected
8776        .timestamp
8777        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
8778    if actual.number != expected.number
8779        || actual.hash != expected.hash
8780        || !exact_parent
8781        || !exact_timestamp
8782    {
8783        return Err(SubscriberOwnerError::BlockMismatch {
8784            expected_number: expected.number,
8785            expected_hash: expected.hash,
8786            actual_number: actual.number,
8787            actual_hash: actual.hash,
8788        });
8789    }
8790    Ok(actual)
8791}
8792
8793fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
8794    let context = log_reactive_context(&log);
8795    ReactiveInputRecord::new(
8796        ReactiveInput::Log(log),
8797        ReactiveContext { source, ..context },
8798    )
8799}
8800
8801fn log_reactive_context(log: &Log) -> ReactiveContext {
8802    let block = match (log.block_hash, log.block_number) {
8803        (Some(hash), Some(number)) => Some(BlockRef {
8804            number,
8805            hash,
8806            parent_hash: None,
8807            timestamp: log.block_timestamp,
8808        }),
8809        _ => None,
8810    };
8811
8812    let chain_status = match (&block, log.removed) {
8813        (Some(block), true) => ChainStatus::Reorged {
8814            dropped_from: block.clone(),
8815        },
8816        (Some(block), false) => ChainStatus::Included {
8817            block: block.clone(),
8818            confirmations: 0,
8819        },
8820        (None, _) => ChainStatus::Pending,
8821    };
8822
8823    ReactiveContext {
8824        chain_id: None,
8825        source: InputSource::Poll,
8826        chain_status,
8827        block,
8828        transaction_index: log.transaction_index,
8829        log_index: log.log_index,
8830    }
8831}
8832
8833fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
8834where
8835    N: Network,
8836{
8837    let block = BlockRef {
8838        number: header.number(),
8839        hash: HeaderResponseTrait::hash(&header),
8840        parent_hash: Some(header.parent_hash()),
8841        timestamp: Some(header.timestamp()),
8842    };
8843    ReactiveInputRecord::new(
8844        ReactiveInput::BlockHeader(header),
8845        ReactiveContext {
8846            chain_id: None,
8847            source: InputSource::Subscription,
8848            chain_status: ChainStatus::Included {
8849                block: block.clone(),
8850                confirmations: 0,
8851            },
8852            block: Some(block),
8853            transaction_index: None,
8854            log_index: None,
8855        },
8856    )
8857}
8858
8859fn pending_hash_input_record<N: Network>(
8860    hash: B256,
8861    source: InputSource,
8862) -> ReactiveInputRecord<N> {
8863    ReactiveInputRecord::new(
8864        ReactiveInput::PendingTxHash(hash),
8865        ReactiveContext {
8866            chain_id: None,
8867            source,
8868            chain_status: ChainStatus::Pending,
8869            block: None,
8870            transaction_index: None,
8871            log_index: None,
8872        },
8873    )
8874}
8875
8876fn provider_error(error: impl fmt::Display) -> SubscriberError {
8877    SubscriberError::Provider(error.to_string())
8878}
8879
8880/// Subscriber error.
8881#[derive(Debug, thiserror::Error)]
8882pub enum SubscriberError {
8883    /// Invalid subscriber configuration.
8884    #[error("{0}")]
8885    InvalidConfig(&'static str),
8886    /// Requested subscriber behavior is not implemented.
8887    #[error("{0}")]
8888    Unsupported(&'static str),
8889    /// Provider or transport error.
8890    #[error("provider error: {0}")]
8891    Provider(String),
8892}