Skip to main content

evm_fork_cache/reactive/
mod.rs

1//! Protocol-neutral reactive runtime for cache state effects.
2//!
3//! The reactive runtime generalizes the log-only [`events`](crate::events)
4//! pipeline into a handler pipeline that can ingest logs, block notifications,
5//! and pending transaction signals. Handlers remain pure synchronous functions:
6//! they read through [`StateView`], return structured
7//! [`ReactiveEffect`] values, and let the runtime validate and commit cache
8//! mutations through [`StateUpdate`].
9//!
10//! This module intentionally contains no protocol, AMM, strategy, signing, or
11//! transaction-submission concepts. Downstream crates can layer those domains on
12//! top by implementing [`ReactiveHandler`] and [`ReactiveHook`].
13
14use std::{
15    any::Any,
16    borrow::Cow,
17    collections::{HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    pin::Pin,
23    sync::Arc,
24    time::Duration,
25};
26
27use alloy_consensus::{BlockHeader as _, Transaction as _};
28use alloy_eips::BlockId;
29use alloy_network::{
30    Ethereum, Network,
31    primitives::{
32        BlockResponse as _, HeaderResponse as HeaderResponseTrait,
33        TransactionResponse as TransactionResponseTrait,
34    },
35};
36use alloy_primitives::{Address, B256, Bytes, U256};
37use alloy_provider::Provider;
38use alloy_rpc_types_eth::{Filter, FilterSet, Log};
39use futures::{
40    StreamExt,
41    stream::{self, BoxStream, SelectAll},
42};
43
44use crate::{
45    cache::EvmCache,
46    events::{EventDecoder, StateView},
47    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
48};
49
50/// Input accepted by the reactive runtime.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum ReactiveInput<N: Network = Ethereum> {
53    /// A canonical or removed EVM log, using Alloy's RPC log type.
54    Log(Log),
55    /// A block header response for header-oriented handlers.
56    BlockHeader(N::HeaderResponse),
57    /// A full block response for block handlers that need transaction bodies.
58    FullBlock(N::BlockResponse),
59    /// A pending transaction hash.
60    PendingTxHash(B256),
61    /// A full pending transaction body.
62    PendingTx(N::TransactionResponse),
63}
64
65/// Context supplied with each [`ReactiveInput`].
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ReactiveContext {
68    /// Chain id, when known.
69    pub chain_id: Option<u64>,
70    /// Where the input came from.
71    pub source: InputSource,
72    /// Lifecycle status of the input.
73    pub chain_status: ChainStatus,
74    /// Block metadata associated with the input, when known.
75    pub block: Option<BlockRef>,
76    /// Transaction index for log or transaction inputs.
77    pub transaction_index: Option<u64>,
78    /// Log index for log inputs.
79    pub log_index: Option<u64>,
80}
81
82/// Minimal block identity carried through reports.
83#[derive(Clone, Debug, PartialEq, Eq, Hash)]
84pub struct BlockRef {
85    /// Block number.
86    pub number: u64,
87    /// Block hash.
88    pub hash: B256,
89    /// Parent hash, when known.
90    pub parent_hash: Option<B256>,
91    /// Block timestamp, when known.
92    pub timestamp: Option<u64>,
93}
94
95/// Lifecycle status for an input.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum ChainStatus {
98    /// The input is mempool-only and must not mutate canonical cache state.
99    Pending,
100    /// The input is included in a block with a confirmation count.
101    Included {
102        /// Included block.
103        block: BlockRef,
104        /// Confirmation count.
105        confirmations: u64,
106    },
107    /// The input is in the chain's safe head.
108    Safe {
109        /// Safe block.
110        block: BlockRef,
111    },
112    /// The input is in the finalized head.
113    Finalized {
114        /// Finalized block.
115        block: BlockRef,
116    },
117    /// The input was dropped by a reorg.
118    Reorged {
119        /// Block the input was dropped from.
120        dropped_from: BlockRef,
121    },
122}
123
124/// Source of an input batch.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
126pub enum InputSource {
127    /// Caller-supplied batch.
128    Batch,
129    /// Live subscription stream.
130    Subscription,
131    /// Polling subscriber.
132    Poll,
133    /// Historical backfill.
134    Backfill,
135    /// Test or synthetic input.
136    Synthetic,
137}
138
139/// Stable identity used for input deduplication and reports.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141pub enum InputRef {
142    /// Stable log identity.
143    Log {
144        /// Chain id, when known.
145        chain_id: Option<u64>,
146        /// Block hash containing the log.
147        block_hash: B256,
148        /// Transaction hash that emitted the log.
149        transaction_hash: B256,
150        /// Log index within the block.
151        log_index: u64,
152    },
153    /// Stable pending transaction identity.
154    PendingTx {
155        /// Chain id, when known.
156        chain_id: Option<u64>,
157        /// Transaction hash.
158        hash: B256,
159    },
160    /// Stable block identity.
161    Block {
162        /// Chain id, when known.
163        chain_id: Option<u64>,
164        /// Block hash.
165        hash: B256,
166        /// Block number.
167        number: u64,
168    },
169}
170
171/// Reliability of state effects emitted by a handler.
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
173pub enum StateEffectQuality {
174    /// Effects are exact from the input alone.
175    ExactFromInput,
176    /// Effects were applied, but follow-up resync is pending.
177    AppliedWithPendingResync,
178    /// Effects came from authoritative resync.
179    ResyncedAuthoritatively,
180    /// State requires repair before it should be trusted.
181    RequiresRepair,
182    /// No canonical state effect was emitted.
183    NoStateEffect,
184}
185
186/// Identifier for a reactive handler.
187#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
188pub struct HandlerId(String);
189
190impl HandlerId {
191    /// Create a handler id.
192    pub fn new(id: impl Into<String>) -> Self {
193        Self(id.into())
194    }
195
196    /// Return the id as a string slice.
197    pub fn as_str(&self) -> &str {
198        &self.0
199    }
200}
201
202impl fmt::Display for HandlerId {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        self.0.fmt(f)
205    }
206}
207
208/// Lightweight report label.
209#[derive(Clone, Debug, PartialEq, Eq, Hash)]
210pub struct ReportTag {
211    /// Label key.
212    pub key: String,
213    /// Label value.
214    pub value: String,
215}
216
217impl ReportTag {
218    /// Create a report tag.
219    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
220        Self {
221            key: key.into(),
222            value: value.into(),
223        }
224    }
225}
226
227/// Domain-neutral hook signal emitted by a handler.
228#[derive(Clone)]
229pub struct HookSignal {
230    /// Signal namespace owned by the caller.
231    pub namespace: Cow<'static, str>,
232    /// Signal kind within the namespace.
233    pub kind: Cow<'static, str>,
234    /// Additional labels for routing or observability.
235    pub labels: Vec<ReportTag>,
236    /// Optional in-process typed payload.
237    pub payload: Option<Arc<dyn Any + Send + Sync>>,
238}
239
240impl fmt::Debug for HookSignal {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.debug_struct("HookSignal")
243            .field("namespace", &self.namespace)
244            .field("kind", &self.kind)
245            .field("labels", &self.labels)
246            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
247            .finish()
248    }
249}
250
251/// Effect emitted by a [`ReactiveHandler`].
252#[derive(Clone, Debug)]
253pub enum ReactiveEffect {
254    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
255    StateUpdate(StateUpdate),
256    /// Request for authoritative state repair.
257    Resync(ResyncRequest),
258    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
259    Invalidate(InvalidationRequest),
260    /// Hook signal dispatched after committed mutation phases.
261    Hook(HookSignal),
262    /// Speculative signal for mempool or downstream work.
263    Speculative(SpeculativeRequest),
264}
265
266/// Handler output for a single input.
267#[derive(Clone, Debug)]
268pub struct HandlerOutcome {
269    /// Effects emitted by the handler.
270    pub effects: Vec<ReactiveEffect>,
271    /// Reliability of emitted state effects.
272    pub quality: StateEffectQuality,
273    /// Labels copied into reports.
274    pub tags: Vec<ReportTag>,
275}
276
277impl HandlerOutcome {
278    /// Construct an empty outcome with the supplied quality.
279    pub fn empty(quality: StateEffectQuality) -> Self {
280        Self {
281            effects: Vec::new(),
282            quality,
283            tags: Vec::new(),
284        }
285    }
286}
287
288/// One input and its execution context.
289#[derive(Clone, Debug)]
290pub struct ReactiveInputRecord<N: Network = Ethereum> {
291    /// Input value.
292    pub input: ReactiveInput<N>,
293    /// Input context.
294    pub context: ReactiveContext,
295}
296
297impl<N: Network> ReactiveInputRecord<N> {
298    /// Create an input record.
299    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
300        Self { input, context }
301    }
302
303    /// Compute the stable input reference used for deduplication.
304    pub fn input_ref(&self) -> InputRef {
305        input_ref(&self.input, &self.context)
306    }
307}
308
309/// Batch of reactive input records.
310#[derive(Clone, Debug)]
311pub struct ReactiveInputBatch<N: Network = Ethereum> {
312    records: Vec<ReactiveInputRecord<N>>,
313}
314
315impl<N: Network> ReactiveInputBatch<N> {
316    /// Create a batch from records.
317    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
318        Self { records }
319    }
320
321    /// Borrow the records in this batch.
322    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
323        &self.records
324    }
325
326    /// Consume the batch into its records.
327    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
328        self.records
329    }
330}
331
332/// Pure synchronous handler for reactive inputs.
333pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
334    /// Stable handler id.
335    fn id(&self) -> HandlerId;
336
337    /// Interests used by subscribers and the local router.
338    fn interests(&self) -> Vec<ReactiveInterest<N>>;
339
340    /// Handle one input against a read-only cache view.
341    fn handle(
342        &self,
343        ctx: &ReactiveContext,
344        input: &ReactiveInput<N>,
345        state: &dyn StateView,
346    ) -> Result<HandlerOutcome, HandlerError>;
347}
348
349/// Hook invoked after reports are built and cache mutation phases have ended.
350pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
351    /// Observe a runtime report.
352    fn on_report(&self, report: Arc<ReactiveReport<N>>);
353}
354
355/// Reactive subscription interest.
356#[allow(clippy::large_enum_variant)]
357#[derive(Clone)]
358pub enum ReactiveInterest<N: Network = Ethereum> {
359    /// Log interest.
360    Logs(LogInterest),
361    /// Block interest.
362    Blocks(BlockInterest),
363    /// Pending transaction interest.
364    PendingTransactions(PendingTxInterest<N>),
365}
366
367impl<N: Network> fmt::Debug for ReactiveInterest<N> {
368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369        match self {
370            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
371            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
372            Self::PendingTransactions(interest) => f
373                .debug_tuple("PendingTransactions")
374                .field(interest)
375                .finish(),
376        }
377    }
378}
379
380/// Interest in logs.
381#[derive(Clone)]
382pub struct LogInterest {
383    /// Provider-side filter.
384    pub provider_filter: Filter,
385    /// Optional local matcher for predicates providers cannot express.
386    pub local_matcher: Option<Arc<dyn LogMatcher>>,
387    /// Optional route-key extraction strategy.
388    pub route_key: Option<RouteKeySpec>,
389}
390
391impl LogInterest {
392    /// Return true if the log matches both the provider filter and local matcher.
393    pub fn matches(&self, log: &Log) -> bool {
394        self.provider_filter.rpc_matches(log)
395            && self
396                .local_matcher
397                .as_ref()
398                .is_none_or(|matcher| matcher.matches(log))
399    }
400
401    /// Extract the route key for a matching log, if configured.
402    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
403        self.route_key.as_ref().and_then(|spec| spec.extract(log))
404    }
405}
406
407impl fmt::Debug for LogInterest {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        f.debug_struct("LogInterest")
410            .field("provider_filter", &self.provider_filter)
411            .field(
412                "local_matcher",
413                &self.local_matcher.as_ref().map(|_| "<matcher>"),
414            )
415            .field("route_key", &self.route_key)
416            .finish()
417    }
418}
419
420/// Local log predicate.
421pub trait LogMatcher: Send + Sync {
422    /// Return true when the log should be routed to the handler.
423    fn matches(&self, log: &Log) -> bool;
424}
425
426/// Route-key extraction strategy for logs.
427#[derive(Clone)]
428pub enum RouteKeySpec {
429    /// Route by emitting address.
430    EmitterAddress,
431    /// Route by indexed topic.
432    Topic {
433        /// Topic index.
434        index: usize,
435    },
436    /// Route by a byte slice in log data.
437    DataSlice {
438        /// Byte offset in the data payload.
439        offset: usize,
440        /// Number of bytes to copy.
441        len: usize,
442    },
443    /// Custom extractor.
444    Custom(Arc<dyn RouteKeyExtractor>),
445}
446
447impl RouteKeySpec {
448    /// Extract a route key from a log.
449    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
450        match self {
451            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
452            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
453            Self::DataSlice { offset, len } => {
454                let data = log.inner.data.data.as_ref();
455                let end = offset.checked_add(*len)?;
456                data.get(*offset..end)
457                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
458            }
459            Self::Custom(extractor) => extractor.extract(log),
460        }
461    }
462}
463
464impl fmt::Debug for RouteKeySpec {
465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466        match self {
467            Self::EmitterAddress => f.write_str("EmitterAddress"),
468            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
469            Self::DataSlice { offset, len } => f
470                .debug_struct("DataSlice")
471                .field("offset", offset)
472                .field("len", len)
473                .finish(),
474            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
475        }
476    }
477}
478
479/// Extracts custom route keys from logs.
480pub trait RouteKeyExtractor: Send + Sync {
481    /// Extract a route key.
482    fn extract(&self, log: &Log) -> Option<RouteKey>;
483}
484
485/// Extracted route key.
486#[derive(Clone, Debug, PartialEq, Eq, Hash)]
487pub enum RouteKey {
488    /// Address key.
489    Address(Address),
490    /// 32-byte key.
491    Bytes32(B256),
492    /// Arbitrary bytes key.
493    Bytes(Vec<u8>),
494}
495
496/// Exact log route selected by [`ReactiveRegistry::route_log`].
497#[derive(Clone, Debug, PartialEq, Eq)]
498pub struct ReactiveLogRoute {
499    /// Handler whose log interest matched.
500    pub handler_id: HandlerId,
501    /// Optional route key extracted from the matching log interest.
502    pub route_key: Option<RouteKey>,
503}
504
505/// Interest in block inputs.
506#[derive(Clone, Debug, PartialEq, Eq, Hash)]
507pub struct BlockInterest {
508    /// Block input mode.
509    pub mode: BlockInterestMode,
510}
511
512impl Default for BlockInterest {
513    fn default() -> Self {
514        Self {
515            mode: BlockInterestMode::Header,
516        }
517    }
518}
519
520/// Block subscription mode.
521#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
522pub enum BlockInterestMode {
523    /// Header-only block input.
524    Header,
525    /// Full block input.
526    FullBlock,
527}
528
529/// Interest in pending transaction inputs.
530#[derive(Clone)]
531pub struct PendingTxInterest<N: Network = Ethereum> {
532    /// Whether the handler requires full transaction bodies.
533    pub full_transactions: bool,
534    /// Sender matcher.
535    pub from: AddressMatcher,
536    /// Recipient matcher.
537    pub to: AddressMatcher,
538    /// Calldata selector matcher.
539    pub selectors: SelectorMatcher,
540    /// Optional local transaction matcher.
541    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
542}
543
544impl<N: Network> Default for PendingTxInterest<N> {
545    fn default() -> Self {
546        Self {
547            full_transactions: false,
548            from: AddressMatcher::Any,
549            to: AddressMatcher::Any,
550            selectors: SelectorMatcher::Any,
551            local_matcher: None,
552        }
553    }
554}
555
556impl<N: Network> PendingTxInterest<N> {
557    fn matches_hash_only(&self) -> bool {
558        !self.full_transactions
559            && self.from.is_any()
560            && self.to.is_any()
561            && self.selectors.is_any()
562            && self.local_matcher.is_none()
563    }
564
565    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
566        self.from.matches(tx.from())
567            && self.to.matches_option(tx.to())
568            && self.selectors.matches(tx.input())
569            && self
570                .local_matcher
571                .as_ref()
572                .is_none_or(|matcher| matcher.matches(tx))
573    }
574}
575
576impl<N: Network> fmt::Debug for PendingTxInterest<N> {
577    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578        f.debug_struct("PendingTxInterest")
579            .field("full_transactions", &self.full_transactions)
580            .field("from", &self.from)
581            .field("to", &self.to)
582            .field("selectors", &self.selectors)
583            .field(
584                "local_matcher",
585                &self.local_matcher.as_ref().map(|_| "<matcher>"),
586            )
587            .finish()
588    }
589}
590
591/// Address matching helper for pending transaction interests.
592#[derive(Clone, Debug, PartialEq, Eq, Hash)]
593pub enum AddressMatcher {
594    /// Match every address.
595    Any,
596    /// Match one address.
597    Exact(Address),
598    /// Match any address in the list.
599    AnyOf(Vec<Address>),
600}
601
602impl AddressMatcher {
603    /// Return true when the matcher is unconstrained.
604    pub fn is_any(&self) -> bool {
605        matches!(self, Self::Any)
606    }
607
608    /// Match a present address.
609    pub fn matches(&self, address: Address) -> bool {
610        match self {
611            Self::Any => true,
612            Self::Exact(expected) => *expected == address,
613            Self::AnyOf(addresses) => addresses.contains(&address),
614        }
615    }
616
617    /// Match an optional address.
618    pub fn matches_option(&self, address: Option<Address>) -> bool {
619        match (self, address) {
620            (Self::Any, _) => true,
621            (_, Some(address)) => self.matches(address),
622            _ => false,
623        }
624    }
625}
626
627/// Calldata selector matching helper.
628#[derive(Clone, Debug, PartialEq, Eq, Hash)]
629pub enum SelectorMatcher {
630    /// Match every selector.
631    Any,
632    /// Match any selector in the list.
633    AnyOf(Vec<[u8; 4]>),
634}
635
636impl SelectorMatcher {
637    /// Return true when the matcher is unconstrained.
638    pub fn is_any(&self) -> bool {
639        matches!(self, Self::Any)
640    }
641
642    /// Match calldata bytes.
643    pub fn matches(&self, input: &Bytes) -> bool {
644        match self {
645            Self::Any => true,
646            Self::AnyOf(selectors) => input
647                .get(..4)
648                .and_then(|bytes| bytes.try_into().ok())
649                .is_some_and(|selector| selectors.contains(&selector)),
650        }
651    }
652}
653
654/// Local predicate over a full pending transaction.
655pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
656    /// Return true when the transaction should be routed to the handler.
657    fn matches(&self, tx: &N::TransactionResponse) -> bool;
658}
659
660/// Request for authoritative state repair.
661#[derive(Clone, Debug, PartialEq, Eq)]
662pub struct ResyncRequest {
663    /// Resync id.
664    pub id: ResyncId,
665    /// Reason for the request.
666    pub reason: ResyncReason,
667    /// Block selection for the read.
668    pub block: ResyncBlock,
669    /// Targets to resync.
670    pub targets: Vec<ResyncTarget>,
671    /// Scheduling priority.
672    pub priority: ResyncPriority,
673}
674
675/// Resync id.
676#[derive(Clone, Debug, PartialEq, Eq, Hash)]
677pub struct ResyncId(String);
678
679impl ResyncId {
680    /// Create a resync id.
681    pub fn new(id: impl Into<String>) -> Self {
682        Self(id.into())
683    }
684}
685
686/// Reason for a resync request.
687#[derive(Clone, Debug, PartialEq, Eq, Hash)]
688pub enum ResyncReason {
689    /// Handler requested repair.
690    HandlerRequested,
691    /// State effect could not be applied completely.
692    SkippedStateEffect,
693    /// Caller-defined reason.
694    Custom(String),
695}
696
697/// Block target for a resync.
698#[derive(Clone, Debug, PartialEq, Eq, Hash)]
699pub enum ResyncBlock {
700    /// Latest block.
701    Latest,
702    /// Safe head.
703    Safe,
704    /// Finalized head.
705    Finalized,
706    /// Block number.
707    Number(u64),
708    /// Block hash and number.
709    Hash {
710        /// Block number.
711        number: u64,
712        /// Block hash.
713        hash: B256,
714        /// Require the hash to still be canonical.
715        require_canonical: bool,
716    },
717}
718
719/// State target for a resync.
720#[derive(Clone, Debug, PartialEq, Eq, Hash)]
721pub enum ResyncTarget {
722    /// One storage slot.
723    StorageSlot {
724        /// Contract address.
725        address: Address,
726        /// Storage slot.
727        slot: U256,
728    },
729    /// Multiple storage slots on one contract.
730    StorageSlots {
731        /// Contract address.
732        address: Address,
733        /// Storage slots.
734        slots: Vec<U256>,
735    },
736    /// Account fields.
737    Account {
738        /// Account address.
739        address: Address,
740        /// Fields to resync.
741        fields: AccountFieldMask,
742    },
743}
744
745/// Account fields requested by a resync.
746#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
747pub struct AccountFieldMask {
748    /// Balance field.
749    pub balance: bool,
750    /// Nonce field.
751    pub nonce: bool,
752    /// Code field.
753    pub code: bool,
754}
755
756/// Resync priority.
757#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
758pub enum ResyncPriority {
759    /// Low priority.
760    Low,
761    /// Normal priority.
762    #[default]
763    Normal,
764    /// High priority.
765    High,
766}
767
768/// Rich invalidation request lowered to [`StateUpdate::Purge`].
769#[derive(Clone, Debug, PartialEq, Eq)]
770pub struct InvalidationRequest {
771    /// Purge scope.
772    pub scope: PurgeScope,
773    /// Address to purge.
774    pub address: Address,
775    /// Reason for reporting.
776    pub reason: InvalidationReason,
777}
778
779/// Invalidation reason.
780#[derive(Clone, Debug, PartialEq, Eq, Hash)]
781pub enum InvalidationReason {
782    /// Handler requested invalidation.
783    HandlerRequested,
784    /// Reorg invalidation.
785    Reorg,
786    /// Caller-defined reason.
787    Custom(String),
788}
789
790/// Speculative signal emitted by handlers.
791#[derive(Clone, Debug, PartialEq, Eq)]
792pub struct SpeculativeRequest {
793    /// Speculative request id.
794    pub id: SpeculativeId,
795    /// Input that triggered the request.
796    pub input_ref: InputRef,
797    /// Labels for downstream routing.
798    pub labels: Vec<ReportTag>,
799}
800
801/// Speculative request id.
802#[derive(Clone, Debug, PartialEq, Eq, Hash)]
803pub struct SpeculativeId(String);
804
805impl SpeculativeId {
806    /// Create a speculative id.
807    pub fn new(id: impl Into<String>) -> Self {
808        Self(id.into())
809    }
810}
811
812/// Configuration for [`ReactiveRuntime`].
813#[derive(Clone, Debug, PartialEq, Eq)]
814pub struct ReactiveConfig {
815    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
816    /// dispatch is synchronous today (every report is delivered to every hook in
817    /// order), so this field is a no-op placeholder for a future async dispatcher.
818    /// Setting it to anything other than the default does not change behavior.
819    pub hook_backpressure: HookBackpressure,
820    /// Reorg journal depth: the number of recent canonical blocks whose effects
821    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
822    /// only blocks still resident in the journal can be recovered. A reorg deeper
823    /// than `journal_depth` recovers the blocks still in the journal and leaves
824    /// the aged-out blocks' effects in place — they are **neither rolled back nor
825    /// purged**, so the freshness/validation loop is the only backstop for that
826    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
827    ///
828    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
829    /// precisely. When a reorg references a block that is no longer in the journal,
830    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
831    /// rather than silent.
832    pub journal_depth: usize,
833}
834
835impl Default for ReactiveConfig {
836    fn default() -> Self {
837        Self {
838            hook_backpressure: HookBackpressure::Block,
839            journal_depth: 64,
840        }
841    }
842}
843
844/// Hook backpressure policy.
845#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
846pub enum HookBackpressure {
847    /// Block the producer until hooks are accepted.
848    Block,
849    /// Drop the newest report under pressure.
850    DropNewest,
851    /// Drop the oldest report under pressure.
852    DropOldest,
853    /// Return an error under pressure.
854    Error,
855}
856
857/// Runtime report.
858#[derive(Clone, Debug)]
859pub enum ReactiveReport<N: Network = Ethereum> {
860    /// Input was accepted after deduplication.
861    Input(InputReport<N>),
862    /// Handlers produced outcomes.
863    Decoded(DecodedReport<N>),
864    /// Direct state effects were applied.
865    Applied(AppliedReport<N>),
866    /// Resync request was scheduled or completed.
867    Resynced(ResyncReport),
868    /// Block-level processing completed.
869    BlockCommitted(BlockReport<N>),
870    /// Reorg processing report.
871    Reorg(ReorgReport<N>),
872    /// Runtime or handler error.
873    Error(ReactiveErrorReport<N>),
874}
875
876/// Input acceptance report.
877#[derive(Clone, Debug)]
878pub struct InputReport<N: Network = Ethereum> {
879    /// Input reference.
880    pub input_ref: InputRef,
881    /// Input context.
882    pub context: ReactiveContext,
883    /// Network marker.
884    pub _network: PhantomData<N>,
885}
886
887/// Decoding report.
888#[derive(Clone, Debug)]
889pub struct DecodedReport<N: Network = Ethereum> {
890    /// Input reference.
891    pub input_ref: InputRef,
892    /// Handler ids that matched the input.
893    pub handler_ids: Vec<HandlerId>,
894    /// Network marker.
895    pub _network: PhantomData<N>,
896}
897
898/// Applied state report.
899#[derive(Clone, Debug)]
900pub struct AppliedReport<N: Network = Ethereum> {
901    /// Input reference.
902    pub input_ref: InputRef,
903    /// Handler that produced the applied effects.
904    pub handler_id: HandlerId,
905    /// State effect quality.
906    pub quality: StateEffectQuality,
907    /// Labels emitted by the handler.
908    pub tags: Vec<ReportTag>,
909    /// Merged state diff from applied updates and invalidations.
910    pub diff: StateDiff,
911    /// State updates applied through the cache.
912    pub state_updates: Vec<StateUpdate>,
913    /// Invalidation requests lowered to purge updates.
914    pub invalidations: Vec<InvalidationRequest>,
915    /// Resync requests surfaced for a scheduler.
916    pub resyncs: Vec<ResyncRequest>,
917    /// Speculative requests surfaced for downstream users.
918    pub speculative: Vec<SpeculativeRequest>,
919    /// Hook signals emitted by the handler.
920    pub hook_signals: Vec<HookSignal>,
921    /// Network marker.
922    pub _network: PhantomData<N>,
923}
924
925/// Report of the storage resync requests executed during an ingest cycle: the
926/// requests considered, the authoritative updates built from successful fetches
927/// (and their applied diff), and any targets that could not be resynced.
928#[derive(Clone, Debug, Default, PartialEq, Eq)]
929pub struct ResyncReport {
930    /// Requests considered by the resync execution pass.
931    pub requested: Vec<ResyncRequest>,
932    /// Authoritative state updates built from successful resync fetches.
933    pub state_updates: Vec<StateUpdate>,
934    /// Diff returned by applying [`state_updates`](Self::state_updates).
935    pub diff: StateDiff,
936    /// Targets that could not be resynced.
937    pub failed: Vec<ResyncFailure>,
938}
939
940/// One resync target that could not be fetched or applied.
941#[derive(Clone, Debug, PartialEq, Eq)]
942pub struct ResyncFailure {
943    /// Request that produced the failed target.
944    pub request_id: ResyncId,
945    /// Block selection used for the failed target.
946    pub block: ResyncBlock,
947    /// Target that could not be resynced.
948    pub target: ResyncTarget,
949    /// Stable failure classification for retry policy and metrics.
950    pub kind: ResyncFailureKind,
951    /// Human-readable failure reason.
952    pub message: String,
953}
954
955/// Stable classification for a failed resync target.
956#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
957pub enum ResyncFailureKind {
958    /// A storage target could not be fetched because no storage batch fetcher is configured.
959    MissingStorageFetcher,
960    /// The storage batch fetcher returned an error for the requested slot.
961    StorageFetchFailed,
962    /// The storage batch fetcher did not return a result for the requested slot.
963    StorageFetchOmitted,
964    /// Account-field resync is not supported by the current provider-neutral cache seam.
965    UnsupportedAccountTarget,
966}
967
968/// Block processing report.
969#[derive(Clone, Debug)]
970pub struct BlockReport<N: Network = Ethereum> {
971    /// Block reference, when known.
972    pub block: Option<BlockRef>,
973    /// Input references committed for the block.
974    pub inputs: Vec<InputRef>,
975    /// Network marker.
976    pub _network: PhantomData<N>,
977}
978
979/// Report of a detected reorg and the recovery it performed: the dropped
980/// block(s) and inputs, the exact rollback updates applied for reversible dropped
981/// effects, the conservative purge updates for irreversible ones, the canceled
982/// hash-pinned resyncs, and why recovery ran.
983///
984/// Recovery only covers blocks still resident in the journal. If a reorg runs
985/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
986/// appear here and their effects are neither rolled back nor purged (the runtime
987/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
988/// backstop for that span.
989#[derive(Clone, Debug)]
990pub struct ReorgReport<N: Network = Ethereum> {
991    /// First dropped block, when known.
992    pub dropped: Option<BlockRef>,
993    /// Blocks dropped from the journal, in ascending journal order.
994    pub dropped_blocks: Vec<BlockRef>,
995    /// Input references that belonged to dropped blocks.
996    pub dropped_inputs: Vec<InputRef>,
997    /// Exact rollback updates applied for reversible dropped effects.
998    pub rollback_updates: Vec<StateUpdate>,
999    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
1000    pub rollback_diff: StateDiff,
1001    /// Conservative purge updates applied for irreversible dropped effects.
1002    pub purge_updates: Vec<StateUpdate>,
1003    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
1004    pub purge_diff: StateDiff,
1005    /// Hash-pinned pending resync requests canceled because their block was dropped.
1006    pub canceled_resyncs: Vec<ResyncRequest>,
1007    /// Reorg trigger.
1008    pub reason: ReorgReason,
1009    /// Network marker.
1010    pub _network: PhantomData<N>,
1011}
1012
1013/// Reason reorg recovery ran.
1014#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1015pub enum ReorgReason {
1016    /// A provider emitted an Alloy removed log.
1017    RemovedLog,
1018    /// The input context explicitly marked an input as reorged.
1019    ReorgedInput,
1020    /// A canonical block did not connect to the journaled head.
1021    ParentMismatch,
1022}
1023
1024/// Report of a non-fatal error surfaced during an ingest cycle, with the
1025/// associated input (when known) and a human-readable message.
1026#[derive(Clone, Debug)]
1027pub struct ReactiveErrorReport<N: Network = Ethereum> {
1028    /// Input associated with the error, when known.
1029    pub input_ref: Option<InputRef>,
1030    /// Error message.
1031    pub message: String,
1032    /// Network marker.
1033    pub _network: PhantomData<N>,
1034}
1035
1036/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
1037/// [`ReactiveRuntime::ingest_batch_with_resync`].
1038#[derive(Clone, Debug)]
1039pub struct ReactiveBatchReport<N: Network = Ethereum> {
1040    /// Applied reports in commit order.
1041    pub applied: Vec<AppliedReport<N>>,
1042    /// Resync requests surfaced during the batch.
1043    pub resyncs: Vec<ResyncRequest>,
1044    /// Speculative requests surfaced during the batch.
1045    pub speculative: Vec<SpeculativeRequest>,
1046    /// Hook reports dispatched after mutation phases.
1047    pub reports: Vec<Arc<ReactiveReport<N>>>,
1048}
1049
1050impl<N: Network> Default for ReactiveBatchReport<N> {
1051    fn default() -> Self {
1052        Self {
1053            applied: Vec::new(),
1054            resyncs: Vec::new(),
1055            speculative: Vec::new(),
1056            reports: Vec::new(),
1057        }
1058    }
1059}
1060
1061/// Error returned by a handler.
1062#[derive(Clone, Debug, PartialEq, Eq)]
1063pub struct HandlerError {
1064    message: String,
1065}
1066
1067impl HandlerError {
1068    /// Create a handler error from a message.
1069    pub fn new(message: impl Into<String>) -> Self {
1070        Self {
1071            message: message.into(),
1072        }
1073    }
1074}
1075
1076impl fmt::Display for HandlerError {
1077    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1078        self.message.fmt(f)
1079    }
1080}
1081
1082impl std::error::Error for HandlerError {}
1083
1084impl From<String> for HandlerError {
1085    fn from(message: String) -> Self {
1086        Self::new(message)
1087    }
1088}
1089
1090impl From<&str> for HandlerError {
1091    fn from(message: &str) -> Self {
1092        Self::new(message)
1093    }
1094}
1095
1096/// Runtime error.
1097#[derive(Debug, thiserror::Error)]
1098pub enum ReactiveError {
1099    /// Handler returned an error.
1100    #[error("handler `{handler_id}` failed: {source}")]
1101    HandlerFailed {
1102        /// Handler id.
1103        handler_id: HandlerId,
1104        /// Handler error.
1105        source: HandlerError,
1106    },
1107    /// Multiple handlers emitted incompatible absolute writes for one input.
1108    #[error(
1109        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
1110    )]
1111    ConflictingEffects {
1112        /// Input reference.
1113        input_ref: Box<InputRef>,
1114        /// Conflicting target.
1115        target: Box<EffectTarget>,
1116        /// First handler id.
1117        first: HandlerId,
1118        /// Second handler id.
1119        second: HandlerId,
1120    },
1121    /// Pending inputs attempted to mutate canonical cache state.
1122    #[error(
1123        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
1124    )]
1125    InvalidPendingEffect {
1126        /// Input reference.
1127        input_ref: Box<InputRef>,
1128        /// Handler id.
1129        handler_id: HandlerId,
1130        /// Effect kind.
1131        effect_kind: &'static str,
1132    },
1133    /// Registration error.
1134    #[error(transparent)]
1135    Register(#[from] RegisterError),
1136}
1137
1138/// Handler registration error.
1139#[derive(Debug, thiserror::Error)]
1140pub enum RegisterError {
1141    /// Duplicate handler id.
1142    #[error("handler id `{0}` is already registered")]
1143    DuplicateHandler(HandlerId),
1144}
1145
1146/// Absolute write target used for conflict reports.
1147#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1148pub enum EffectTarget {
1149    /// Storage slot target.
1150    StorageSlot {
1151        /// Contract address.
1152        address: Address,
1153        /// Storage slot.
1154        slot: U256,
1155    },
1156    /// Account balance target.
1157    AccountBalance {
1158        /// Account address.
1159        address: Address,
1160    },
1161    /// Account nonce target.
1162    AccountNonce {
1163        /// Account address.
1164        address: Address,
1165    },
1166    /// Account code target.
1167    AccountCode {
1168        /// Account address.
1169        address: Address,
1170    },
1171    /// Masked storage slot target.
1172    MaskedStorageSlot {
1173        /// Contract address.
1174        address: Address,
1175        /// Storage slot.
1176        slot: U256,
1177        /// Bit mask.
1178        mask: U256,
1179    },
1180}
1181
1182#[derive(Clone, Debug, PartialEq, Eq)]
1183enum AbsoluteValue {
1184    U256(U256),
1185    U64(u64),
1186    Bytes(Bytes),
1187}
1188
1189/// Reactive runtime.
1190pub struct ReactiveRuntime<N: Network = Ethereum> {
1191    registry: ReactiveRegistry<N>,
1192    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
1193    config: ReactiveConfig,
1194    journal: VecDeque<BlockJournal<N>>,
1195    pending_resyncs: Vec<ResyncRequest>,
1196}
1197
1198#[derive(Clone, Debug)]
1199struct BlockJournal<N: Network = Ethereum> {
1200    block: BlockRef,
1201    inputs: Vec<InputRef>,
1202    applied: Vec<AppliedReport<N>>,
1203    resynced: Vec<ResyncReport>,
1204}
1205
1206/// Registry and router for provider-neutral reactive handlers.
1207///
1208/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
1209/// consolidated provider-side log filters for subscription setup, and routes
1210/// provider logs back to the exact matching log interests. Consolidated filters
1211/// may be safe supersets; [`Self::route_log`] always re-checks the original
1212/// [`LogInterest`] and its local matcher before returning a route.
1213pub struct ReactiveRegistry<N: Network = Ethereum> {
1214    handlers: Vec<RegisteredHandler<N>>,
1215}
1216
1217struct RegisteredHandler<N: Network = Ethereum> {
1218    id: HandlerId,
1219    handler: Arc<dyn ReactiveHandler<N>>,
1220    interests: Vec<ReactiveInterest<N>>,
1221}
1222
1223impl<N: Network> Default for ReactiveRegistry<N> {
1224    fn default() -> Self {
1225        Self::new()
1226    }
1227}
1228
1229impl<N: Network> ReactiveRegistry<N> {
1230    /// Create an empty registry.
1231    pub fn new() -> Self {
1232        Self {
1233            handlers: Vec::new(),
1234        }
1235    }
1236
1237    /// Register a handler, preserving registration order.
1238    ///
1239    /// Duplicate handler ids are rejected with
1240    /// [`RegisterError::DuplicateHandler`].
1241    pub fn register_handler(
1242        &mut self,
1243        handler: Arc<dyn ReactiveHandler<N>>,
1244    ) -> Result<(), RegisterError> {
1245        let id = handler.id();
1246        if self.handlers.iter().any(|registered| registered.id == id) {
1247            return Err(RegisterError::DuplicateHandler(id));
1248        }
1249        let interests = handler.interests();
1250        self.handlers.push(RegisteredHandler {
1251            id,
1252            handler,
1253            interests,
1254        });
1255        Ok(())
1256    }
1257
1258    /// Return all registered interests in handler registration order.
1259    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
1260        self.handlers
1261            .iter()
1262            .flat_map(|handler| handler.interests.clone())
1263            .collect()
1264    }
1265
1266    /// Return consolidated provider-side log filters.
1267    ///
1268    /// Filters are emitted in deterministic first-registration order by
1269    /// compatible block option. Within each returned filter, address and topic
1270    /// sets are unioned independently, which can intentionally overfetch. Use
1271    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
1272    pub fn log_subscription_filters(&self) -> Vec<Filter> {
1273        let mut filters = Vec::new();
1274        for interest in self.log_interests() {
1275            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
1276        }
1277        filters
1278    }
1279
1280    /// Route a log to exact matching handler interests.
1281    ///
1282    /// Routes are returned in handler registration order. Each handler appears
1283    /// at most once for a log, using the first matching log interest declared by
1284    /// that handler.
1285    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
1286        self.handlers
1287            .iter()
1288            .filter_map(|handler| handler.route_log(log))
1289            .collect()
1290    }
1291
1292    fn handlers(&self) -> &[RegisteredHandler<N>] {
1293        &self.handlers
1294    }
1295
1296    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
1297        self.handlers.iter().flat_map(|handler| {
1298            handler
1299                .interests
1300                .iter()
1301                .filter_map(|interest| match interest {
1302                    ReactiveInterest::Logs(interest) => Some(interest),
1303                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
1304                })
1305        })
1306    }
1307}
1308
1309impl<N: Network> ReactiveRuntime<N> {
1310    /// Create an empty runtime.
1311    pub fn new(config: ReactiveConfig) -> Self {
1312        Self {
1313            registry: ReactiveRegistry::new(),
1314            hooks: Vec::new(),
1315            config,
1316            journal: VecDeque::new(),
1317            pending_resyncs: Vec::new(),
1318        }
1319    }
1320
1321    /// Register a handler.
1322    pub fn register_handler(
1323        &mut self,
1324        handler: Arc<dyn ReactiveHandler<N>>,
1325    ) -> Result<(), RegisterError> {
1326        self.registry.register_handler(handler)
1327    }
1328
1329    /// Register a hook.
1330    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
1331        self.hooks.push(hook);
1332        Ok(())
1333    }
1334
1335    /// Return all registered interests in handler registration order.
1336    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
1337        self.registry.interests()
1338    }
1339
1340    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
1341    pub fn ingest_batch(
1342        &mut self,
1343        cache: &mut EvmCache,
1344        batch: ReactiveInputBatch<N>,
1345    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
1346        let batch_report = self.ingest_batch_direct(cache, batch)?;
1347        self.dispatch_reports(&batch_report.reports);
1348        let _ = &self.config;
1349        Ok(batch_report)
1350    }
1351
1352    /// Ingest a batch, then execute surfaced storage resync requests.
1353    ///
1354    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
1355    /// direct handler effects, then runs a synchronous resync phase over the
1356    /// collected [`ResyncRequest`]s. Storage targets are fetched through
1357    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
1358    /// values are applied as [`StateUpdate::slot`] updates through
1359    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
1360    /// in [`ResyncReport::failed`]. It does not start subscribers, background
1361    /// workers, or network transport.
1362    pub fn ingest_batch_with_resync(
1363        &mut self,
1364        cache: &mut EvmCache,
1365        batch: ReactiveInputBatch<N>,
1366    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
1367        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
1368
1369        if !batch_report.resyncs.is_empty() {
1370            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
1371            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
1372            self.record_journal_resync(&resync_report);
1373            batch_report
1374                .reports
1375                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
1376        }
1377
1378        self.dispatch_reports(&batch_report.reports);
1379        let _ = &self.config;
1380        Ok(batch_report)
1381    }
1382
1383    fn ingest_batch_direct(
1384        &mut self,
1385        cache: &mut EvmCache,
1386        batch: ReactiveInputBatch<N>,
1387    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
1388        let records = sort_records(dedupe_records(batch.into_records()));
1389
1390        let mut batch_report = ReactiveBatchReport::default();
1391        let mut reports_to_dispatch = Vec::new();
1392
1393        for record in records {
1394            let input_ref = record.input_ref();
1395            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
1396                input_ref,
1397                context: record.context.clone(),
1398                _network: PhantomData,
1399            })));
1400
1401            if let Some(reorg_report) = self.recover_for_canonical_input(cache, &record) {
1402                remove_canceled_resyncs_from_batch(
1403                    &mut batch_report.resyncs,
1404                    &reorg_report.canceled_resyncs,
1405                );
1406                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
1407            }
1408
1409            if let Some(reorg_report) = self.recover_for_reorged_input(cache, &record) {
1410                remove_canceled_resyncs_from_batch(
1411                    &mut batch_report.resyncs,
1412                    &reorg_report.canceled_resyncs,
1413                );
1414                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
1415                continue;
1416            }
1417
1418            if let Some(block) = canonical_record_block(&record) {
1419                self.record_journal_input(block, input_ref);
1420            }
1421
1422            let executions = self.execute_handlers(cache, &record, input_ref)?;
1423            if executions.is_empty() {
1424                continue;
1425            }
1426
1427            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
1428                input_ref,
1429                handler_ids: executions
1430                    .iter()
1431                    .map(|execution| execution.handler_id.clone())
1432                    .collect(),
1433                _network: PhantomData,
1434            })));
1435
1436            detect_conflicts(input_ref, &executions)?;
1437
1438            for execution in executions {
1439                let diff = if execution.state_updates.is_empty() {
1440                    StateDiff::default()
1441                } else {
1442                    cache.apply_updates(&execution.state_updates)
1443                };
1444
1445                batch_report
1446                    .resyncs
1447                    .extend(execution.resyncs.iter().cloned());
1448                self.pending_resyncs
1449                    .extend(execution.resyncs.iter().cloned());
1450                batch_report
1451                    .speculative
1452                    .extend(execution.speculative.iter().cloned());
1453
1454                let applied = AppliedReport {
1455                    input_ref,
1456                    handler_id: execution.handler_id,
1457                    quality: execution.quality,
1458                    tags: execution.tags,
1459                    diff,
1460                    state_updates: execution.state_updates,
1461                    invalidations: execution.invalidations,
1462                    resyncs: execution.resyncs,
1463                    speculative: execution.speculative,
1464                    hook_signals: execution.hook_signals,
1465                    _network: PhantomData,
1466                };
1467                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
1468                reports_to_dispatch.push(report);
1469                if let Some(block) = canonical_record_block(&record) {
1470                    self.record_journal_applied(block, applied.clone());
1471                }
1472                batch_report.applied.push(applied);
1473            }
1474        }
1475
1476        batch_report.reports = reports_to_dispatch;
1477        Ok(batch_report)
1478    }
1479
1480    fn execute_handlers(
1481        &self,
1482        cache: &EvmCache,
1483        record: &ReactiveInputRecord<N>,
1484        input_ref: InputRef,
1485    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
1486        let mut executions = Vec::new();
1487        for registered in self.registry.handlers() {
1488            if !registered.matches(&record.input) {
1489                continue;
1490            }
1491
1492            let outcome = registered
1493                .handler
1494                .handle(&record.context, &record.input, cache)
1495                .map_err(|source| ReactiveError::HandlerFailed {
1496                    handler_id: registered.id.clone(),
1497                    source,
1498                })?;
1499
1500            validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)?;
1501            executions.push(HandlerExecution::from_outcome(
1502                registered.id.clone(),
1503                input_ref,
1504                outcome,
1505            ));
1506        }
1507        Ok(executions)
1508    }
1509
1510    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
1511        for report in reports {
1512            for hook in &self.hooks {
1513                hook.on_report(report.clone());
1514            }
1515        }
1516    }
1517
1518    fn recover_for_canonical_input(
1519        &mut self,
1520        cache: &mut EvmCache,
1521        record: &ReactiveInputRecord<N>,
1522    ) -> Option<ReorgReport<N>> {
1523        let block = canonical_record_block(record)?;
1524        let latest = self.journal.back()?.block.clone();
1525
1526        if self
1527            .journal
1528            .iter()
1529            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
1530        {
1531            return None;
1532        }
1533
1534        if block.number == latest.number.saturating_add(1) && block.parent_hash == Some(latest.hash)
1535        {
1536            return None;
1537        }
1538
1539        if block.number > latest.number.saturating_add(1) {
1540            return None;
1541        }
1542
1543        let dropped = if let Some(parent_hash) = block.parent_hash {
1544            if let Some(parent_index) = self
1545                .journal
1546                .iter()
1547                .rposition(|entry| entry.block.hash == parent_hash)
1548            {
1549                self.drain_journal_after(parent_index)
1550            } else {
1551                self.warn_under_recovery(block.number);
1552                self.drain_journal_from_number(block.number)
1553            }
1554        } else {
1555            self.warn_under_recovery(block.number);
1556            self.drain_journal_from_number(block.number)
1557        };
1558
1559        self.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
1560    }
1561
1562    fn recover_for_reorged_input(
1563        &mut self,
1564        cache: &mut EvmCache,
1565        record: &ReactiveInputRecord<N>,
1566    ) -> Option<ReorgReport<N>> {
1567        let (dropped_block, reason) = reorg_signal_block(record)?;
1568        let dropped = if let Some(index) = self
1569            .journal
1570            .iter()
1571            .position(|entry| entry.block.hash == dropped_block.hash)
1572        {
1573            self.drain_journal_from(index)
1574        } else {
1575            self.warn_under_recovery(dropped_block.number);
1576            self.drain_journal_from_number(dropped_block.number)
1577        };
1578
1579        if dropped.is_empty() {
1580            let canceled_resyncs =
1581                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
1582            if canceled_resyncs.is_empty() {
1583                return None;
1584            }
1585            return Some(ReorgReport {
1586                dropped: Some(dropped_block.clone()),
1587                dropped_blocks: vec![dropped_block],
1588                dropped_inputs: Vec::new(),
1589                rollback_updates: Vec::new(),
1590                rollback_diff: StateDiff::default(),
1591                purge_updates: Vec::new(),
1592                purge_diff: StateDiff::default(),
1593                canceled_resyncs,
1594                reason,
1595                _network: PhantomData,
1596            });
1597        }
1598
1599        self.recover_dropped_journals(cache, dropped, reason)
1600    }
1601
1602    /// Warn that a reorg references a block no longer resident in the journal, so
1603    /// recovery is limited to the blocks still journaled — effects from aged-out
1604    /// blocks are neither rolled back nor purged (the freshness/validation loop is
1605    /// the backstop). Makes the under-recovery observable instead of silent.
1606    fn warn_under_recovery(&self, reorg_number: u64) {
1607        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
1608        tracing::warn!(
1609            reorg_block = reorg_number,
1610            oldest_journaled = ?oldest_journaled,
1611            journal_depth = self.config.journal_depth,
1612            "reactive reorg recovery is incomplete: the reorged block is no longer \
1613             in the journal, so effects from blocks aged out of the journal are \
1614             neither rolled back nor purged (the freshness/validation loop is the \
1615             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
1616             reorgs precisely."
1617        );
1618    }
1619
1620    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
1621        let entry = self.journal_entry_mut(block);
1622        if !entry.inputs.contains(&input_ref) {
1623            entry.inputs.push(input_ref);
1624        }
1625        self.trim_journal();
1626    }
1627
1628    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
1629        self.journal_entry_mut(block).applied.push(applied);
1630        self.trim_journal();
1631    }
1632
1633    fn record_journal_resync(&mut self, report: &ResyncReport) {
1634        if report.diff.is_empty() {
1635            return;
1636        }
1637        let Some(block) = single_hash_pinned_resync_block(report) else {
1638            return;
1639        };
1640        self.journal_entry_mut(&block).resynced.push(report.clone());
1641        self.trim_journal();
1642    }
1643
1644    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
1645        if let Some(index) = self
1646            .journal
1647            .iter()
1648            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
1649        {
1650            return &mut self.journal[index];
1651        }
1652
1653        self.journal.push_back(BlockJournal {
1654            block: block.clone(),
1655            inputs: Vec::new(),
1656            applied: Vec::new(),
1657            resynced: Vec::new(),
1658        });
1659        let index = self.journal.len() - 1;
1660        &mut self.journal[index]
1661    }
1662
1663    fn trim_journal(&mut self) {
1664        if self.config.journal_depth == 0 {
1665            self.journal.clear();
1666            return;
1667        }
1668        while self.journal.len() > self.config.journal_depth {
1669            self.journal.pop_front();
1670        }
1671    }
1672
1673    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
1674        self.journal.drain((index + 1)..).collect()
1675    }
1676
1677    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
1678        self.journal.drain(index..).collect()
1679    }
1680
1681    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
1682        let Some(index) = self
1683            .journal
1684            .iter()
1685            .position(|entry| entry.block.number >= number)
1686        else {
1687            return Vec::new();
1688        };
1689        self.drain_journal_from(index)
1690    }
1691
1692    fn recover_dropped_journals(
1693        &mut self,
1694        cache: &mut EvmCache,
1695        dropped: Vec<BlockJournal<N>>,
1696        reason: ReorgReason,
1697    ) -> Option<ReorgReport<N>> {
1698        if dropped.is_empty() {
1699            return None;
1700        }
1701
1702        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block.clone()).collect();
1703        let dropped_inputs: Vec<_> = dropped
1704            .iter()
1705            .flat_map(|entry| entry.inputs.iter().copied())
1706            .collect();
1707        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
1708        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
1709        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
1710        let purge_updates: Vec<_> = purge_scopes
1711            .iter()
1712            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
1713            .collect();
1714
1715        let rollback_diff = if rollback_updates.is_empty() {
1716            StateDiff::default()
1717        } else {
1718            cache.apply_updates(&rollback_updates)
1719        };
1720        let purge_diff = if purge_updates.is_empty() {
1721            StateDiff::default()
1722        } else {
1723            cache.apply_updates(&purge_updates)
1724        };
1725
1726        Some(ReorgReport {
1727            dropped: dropped_blocks.first().cloned(),
1728            dropped_blocks,
1729            dropped_inputs,
1730            rollback_updates,
1731            rollback_diff,
1732            purge_updates,
1733            purge_diff,
1734            canceled_resyncs,
1735            reason,
1736            _network: PhantomData,
1737        })
1738    }
1739
1740    fn cancel_resyncs_for_dropped_blocks(
1741        &mut self,
1742        dropped_blocks: &[BlockRef],
1743    ) -> Vec<ResyncRequest> {
1744        let mut canceled = Vec::new();
1745        self.pending_resyncs.retain(|request| {
1746            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
1747            if should_cancel {
1748                canceled.push(request.clone());
1749            }
1750            !should_cancel
1751        });
1752        canceled
1753    }
1754
1755    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
1756        let ids: HashSet<_> = ids.into_iter().cloned().collect();
1757        self.pending_resyncs
1758            .retain(|request| !ids.contains(&request.id));
1759    }
1760}
1761
1762fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
1763    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
1764        return None;
1765    }
1766    if is_canonical_status(&record.context.chain_status) {
1767        return context_block_ref(&record.context);
1768    }
1769    None
1770}
1771
1772fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
1773    match &ctx.chain_status {
1774        ChainStatus::Included { block, .. }
1775        | ChainStatus::Safe { block }
1776        | ChainStatus::Finalized { block } => Some(block),
1777        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
1778        ChainStatus::Pending => ctx.block.as_ref(),
1779    }
1780}
1781
1782fn reorg_signal_block<N: Network>(
1783    record: &ReactiveInputRecord<N>,
1784) -> Option<(BlockRef, ReorgReason)> {
1785    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
1786        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
1787    }
1788
1789    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
1790        return Some((dropped_from.clone(), ReorgReason::ReorgedInput));
1791    }
1792
1793    None
1794}
1795
1796fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
1797    context_block_ref(&record.context)
1798        .cloned()
1799        .or_else(|| match &record.input {
1800            ReactiveInput::Log(log) => Some(BlockRef {
1801                number: log.block_number?,
1802                hash: log.block_hash?,
1803                parent_hash: None,
1804                timestamp: log.block_timestamp,
1805            }),
1806            ReactiveInput::BlockHeader(header) => Some(BlockRef {
1807                number: header.number(),
1808                hash: header.hash(),
1809                parent_hash: Some(header.parent_hash()),
1810                timestamp: Some(header.timestamp()),
1811            }),
1812            ReactiveInput::FullBlock(block) => {
1813                let header = block.header();
1814                Some(BlockRef {
1815                    number: header.number(),
1816                    hash: header.hash(),
1817                    parent_hash: Some(header.parent_hash()),
1818                    timestamp: Some(header.timestamp()),
1819                })
1820            }
1821            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
1822        })
1823}
1824
1825fn remove_canceled_resyncs_from_batch(
1826    resyncs: &mut Vec<ResyncRequest>,
1827    canceled: &[ResyncRequest],
1828) {
1829    if canceled.is_empty() {
1830        return;
1831    }
1832    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
1833    resyncs.retain(|request| !canceled_ids.contains(&request.id));
1834}
1835
1836fn resync_request_targets_dropped_block(
1837    request: &ResyncRequest,
1838    dropped_blocks: &[BlockRef],
1839) -> bool {
1840    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
1841        return false;
1842    };
1843    dropped_blocks
1844        .iter()
1845        .any(|block| block.hash == *hash && block.number == *number)
1846}
1847
1848fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
1849    let first = report.requested.first()?.block.clone();
1850    if !report
1851        .requested
1852        .iter()
1853        .all(|request| request.block == first)
1854    {
1855        return None;
1856    }
1857
1858    let ResyncBlock::Hash { number, hash, .. } = first else {
1859        return None;
1860    };
1861
1862    Some(BlockRef {
1863        number,
1864        hash,
1865        parent_hash: None,
1866        timestamp: None,
1867    })
1868}
1869
1870fn purge_scopes_for_dropped_journals<N: Network>(
1871    dropped: &[BlockJournal<N>],
1872) -> Vec<(Address, PurgeScope)> {
1873    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
1874    for entry in dropped.iter().rev() {
1875        for resynced in entry.resynced.iter().rev() {
1876            merge_purge_scopes_for_diff(&mut scopes, &resynced.diff);
1877        }
1878        for applied in entry.applied.iter().rev() {
1879            merge_purge_scopes_for_diff(&mut scopes, &applied.diff);
1880        }
1881    }
1882    scopes
1883}
1884
1885fn rollback_updates_for_dropped_journals<N: Network>(
1886    dropped: &[BlockJournal<N>],
1887    purge_scopes: &[(Address, PurgeScope)],
1888) -> Vec<StateUpdate> {
1889    let purge_addresses: HashSet<_> = purge_scopes
1890        .iter()
1891        .map(|(address, _scope)| *address)
1892        .collect();
1893    let mut updates = Vec::new();
1894    for entry in dropped.iter().rev() {
1895        for resynced in entry.resynced.iter().rev() {
1896            push_rollback_updates_for_diff(&mut updates, &resynced.diff, &purge_addresses);
1897        }
1898        for applied in entry.applied.iter().rev() {
1899            push_rollback_updates_for_diff(&mut updates, &applied.diff, &purge_addresses);
1900        }
1901    }
1902    updates
1903}
1904
1905fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
1906    for change in &diff.accounts {
1907        merge_purge_scope(scopes, change.address, PurgeScope::Account);
1908    }
1909    for record in &diff.purged {
1910        merge_purge_scope(scopes, record.address, record.scope.clone());
1911    }
1912}
1913
1914fn push_rollback_updates_for_diff(
1915    updates: &mut Vec<StateUpdate>,
1916    diff: &StateDiff,
1917    purge_addresses: &HashSet<Address>,
1918) {
1919    for change in diff.slots.iter().rev() {
1920        if purge_addresses.contains(&change.address) {
1921            continue;
1922        }
1923        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
1924    }
1925}
1926
1927fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
1928    if let Some((_existing_address, existing_scope)) = scopes
1929        .iter_mut()
1930        .find(|(existing_address, _scope)| *existing_address == address)
1931    {
1932        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
1933    } else {
1934        scopes.push((address, scope));
1935    }
1936}
1937
1938fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
1939    match (left, right) {
1940        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
1941        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
1942        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
1943            for slot in right {
1944                if !left.contains(&slot) {
1945                    left.push(slot);
1946                }
1947            }
1948            PurgeScope::Slots(left)
1949        }
1950    }
1951}
1952
1953#[derive(Clone, Debug)]
1954struct StorageFetchSlot {
1955    address: Address,
1956    slot: U256,
1957    origins: Vec<StorageFetchOrigin>,
1958}
1959
1960#[derive(Clone, Debug)]
1961struct StorageFetchOrigin {
1962    request_id: ResyncId,
1963    target: ResyncTarget,
1964}
1965
1966#[derive(Clone, Debug)]
1967struct StorageFetchGroup {
1968    block: ResyncBlock,
1969    slots: Vec<StorageFetchSlot>,
1970    seen: HashSet<(Address, U256)>,
1971}
1972
1973fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
1974    let mut failed = Vec::new();
1975    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
1976
1977    for request in requests {
1978        for target in &request.targets {
1979            match target {
1980                ResyncTarget::StorageSlot { address, slot } => {
1981                    push_storage_resync_slot(
1982                        &mut storage_groups,
1983                        &request.id,
1984                        &request.block,
1985                        *address,
1986                        *slot,
1987                    );
1988                }
1989                ResyncTarget::StorageSlots { address, slots } => {
1990                    for slot in slots {
1991                        push_storage_resync_slot(
1992                            &mut storage_groups,
1993                            &request.id,
1994                            &request.block,
1995                            *address,
1996                            *slot,
1997                        );
1998                    }
1999                }
2000                ResyncTarget::Account { .. } => failed.push(ResyncFailure {
2001                    request_id: request.id.clone(),
2002                    block: request.block.clone(),
2003                    target: target.clone(),
2004                    kind: ResyncFailureKind::UnsupportedAccountTarget,
2005                    message:
2006                        "account resync is unsupported until a provider-neutral account fetcher exists"
2007                            .to_string(),
2008                }),
2009            }
2010        }
2011    }
2012
2013    let mut state_updates = Vec::new();
2014    if !storage_groups.is_empty() {
2015        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
2016            for group in storage_groups {
2017                let block = group.block.clone();
2018                let fetches: Vec<(Address, U256)> = group
2019                    .slots
2020                    .iter()
2021                    .map(|slot| (slot.address, slot.slot))
2022                    .collect();
2023                let results = (fetcher)(fetches, Some(resync_block_to_block_id(&block)));
2024                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
2025                    .slots
2026                    .iter()
2027                    .cloned()
2028                    .map(|slot| ((slot.address, slot.slot), slot))
2029                    .collect();
2030
2031                for (address, slot, fetched) in results {
2032                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
2033                        continue;
2034                    };
2035                    match fetched {
2036                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
2037                        Err(error) => {
2038                            let message = error.to_string();
2039                            push_resync_failures(
2040                                &mut failed,
2041                                &block,
2042                                requested_slot.origins,
2043                                ResyncFailureKind::StorageFetchFailed,
2044                                message,
2045                            );
2046                        }
2047                    }
2048                }
2049
2050                for requested_slot in group.slots {
2051                    if pending
2052                        .remove(&(requested_slot.address, requested_slot.slot))
2053                        .is_some()
2054                    {
2055                        push_resync_failures(
2056                            &mut failed,
2057                            &block,
2058                            requested_slot.origins,
2059                            ResyncFailureKind::StorageFetchOmitted,
2060                            "storage batch fetcher did not return a value for slot".to_string(),
2061                        );
2062                    }
2063                }
2064            }
2065        } else {
2066            for group in storage_groups {
2067                let block = group.block.clone();
2068                for slot in group.slots {
2069                    push_resync_failures(
2070                        &mut failed,
2071                        &block,
2072                        slot.origins,
2073                        ResyncFailureKind::MissingStorageFetcher,
2074                        "storage resync requires a storage batch fetcher".to_string(),
2075                    );
2076                }
2077            }
2078        }
2079    }
2080
2081    let diff = if state_updates.is_empty() {
2082        StateDiff::default()
2083    } else {
2084        cache.apply_updates(&state_updates)
2085    };
2086
2087    ResyncReport {
2088        requested: requests.to_vec(),
2089        state_updates,
2090        diff,
2091        failed,
2092    }
2093}
2094
2095fn push_resync_failures(
2096    failed: &mut Vec<ResyncFailure>,
2097    block: &ResyncBlock,
2098    origins: Vec<StorageFetchOrigin>,
2099    kind: ResyncFailureKind,
2100    message: String,
2101) {
2102    for origin in origins {
2103        failed.push(ResyncFailure {
2104            request_id: origin.request_id,
2105            block: block.clone(),
2106            target: origin.target,
2107            kind,
2108            message: message.clone(),
2109        });
2110    }
2111}
2112
2113fn push_storage_resync_slot(
2114    groups: &mut Vec<StorageFetchGroup>,
2115    request_id: &ResyncId,
2116    block: &ResyncBlock,
2117    address: Address,
2118    slot: U256,
2119) {
2120    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
2121        index
2122    } else {
2123        groups.push(StorageFetchGroup {
2124            block: block.clone(),
2125            slots: Vec::new(),
2126            seen: HashSet::new(),
2127        });
2128        groups.len() - 1
2129    };
2130
2131    let group = &mut groups[group_index];
2132    let origin = StorageFetchOrigin {
2133        request_id: request_id.clone(),
2134        target: ResyncTarget::StorageSlot { address, slot },
2135    };
2136    if group.seen.insert((address, slot)) {
2137        group.slots.push(StorageFetchSlot {
2138            address,
2139            slot,
2140            origins: vec![origin],
2141        });
2142    } else if let Some(existing) = group
2143        .slots
2144        .iter_mut()
2145        .find(|existing| existing.address == address && existing.slot == slot)
2146    {
2147        existing.origins.push(origin);
2148    }
2149}
2150
2151fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
2152    match block {
2153        ResyncBlock::Latest => BlockId::latest(),
2154        ResyncBlock::Safe => BlockId::safe(),
2155        ResyncBlock::Finalized => BlockId::finalized(),
2156        ResyncBlock::Number(number) => BlockId::number(*number),
2157        ResyncBlock::Hash {
2158            number: _,
2159            hash,
2160            require_canonical,
2161        } => BlockId::from((*hash, Some(*require_canonical))),
2162    }
2163}
2164
2165impl<N: Network> RegisteredHandler<N> {
2166    fn matches(&self, input: &ReactiveInput<N>) -> bool {
2167        self.interests
2168            .iter()
2169            .any(|interest| interest_matches(interest, input))
2170    }
2171
2172    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
2173        self.interests.iter().find_map(|interest| match interest {
2174            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
2175                handler_id: self.id.clone(),
2176                route_key: interest.route_key(log),
2177            }),
2178            ReactiveInterest::Logs(_)
2179            | ReactiveInterest::Blocks(_)
2180            | ReactiveInterest::PendingTransactions(_) => None,
2181        })
2182    }
2183}
2184
2185fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
2186    if let Some(existing) = filters
2187        .iter_mut()
2188        .find(|existing| existing.block_option == next.block_option)
2189    {
2190        merge_filter_set(&mut existing.address, &next.address);
2191        for (existing_topic, next_topic) in existing.topics.iter_mut().zip(next.topics.iter()) {
2192            merge_filter_set(existing_topic, next_topic);
2193        }
2194    } else {
2195        filters.push(next.clone());
2196    }
2197}
2198
2199fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
2200    if target.is_empty() {
2201        return;
2202    }
2203    if source.is_empty() {
2204        *target = FilterSet::default();
2205        return;
2206    }
2207    for value in source.iter() {
2208        target.insert(value.clone());
2209    }
2210}
2211
2212#[derive(Clone, Debug)]
2213struct HandlerExecution {
2214    handler_id: HandlerId,
2215    quality: StateEffectQuality,
2216    tags: Vec<ReportTag>,
2217    state_updates: Vec<StateUpdate>,
2218    invalidations: Vec<InvalidationRequest>,
2219    resyncs: Vec<ResyncRequest>,
2220    speculative: Vec<SpeculativeRequest>,
2221    hook_signals: Vec<HookSignal>,
2222}
2223
2224impl HandlerExecution {
2225    fn from_outcome(handler_id: HandlerId, input_ref: InputRef, outcome: HandlerOutcome) -> Self {
2226        let mut state_updates = Vec::new();
2227        let mut invalidations = Vec::new();
2228        let mut resyncs = Vec::new();
2229        let mut speculative = Vec::new();
2230        let mut hook_signals = Vec::new();
2231
2232        for effect in outcome.effects {
2233            match effect {
2234                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
2235                ReactiveEffect::Invalidate(invalidation) => {
2236                    state_updates.push(StateUpdate::purge(
2237                        invalidation.address,
2238                        invalidation.scope.clone(),
2239                    ));
2240                    invalidations.push(invalidation);
2241                }
2242                ReactiveEffect::Resync(request) => resyncs.push(request),
2243                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
2244                ReactiveEffect::Speculative(mut request) => {
2245                    request.input_ref = input_ref;
2246                    speculative.push(request);
2247                }
2248            }
2249        }
2250
2251        Self {
2252            handler_id,
2253            quality: outcome.quality,
2254            tags: outcome.tags,
2255            state_updates,
2256            invalidations,
2257            resyncs,
2258            speculative,
2259            hook_signals,
2260        }
2261    }
2262}
2263
2264fn dedupe_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
2265    let mut seen = HashSet::new();
2266    let mut deduped = Vec::with_capacity(records.len());
2267    for record in records {
2268        if seen.insert(record.input_ref()) {
2269            deduped.push(record);
2270        }
2271    }
2272    deduped
2273}
2274
2275fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
2276    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
2277        records.into_iter().enumerate().collect();
2278    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
2279    indexed.into_iter().map(|(_, record)| record).collect()
2280}
2281
2282fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
2283    if let ReactiveInput::Log(log) = &record.input
2284        && is_canonical_status(&record.context.chain_status)
2285        && !log.removed
2286    {
2287        return RecordSortKey {
2288            class: 0,
2289            block_number: log
2290                .block_number
2291                .or(record.context.block.as_ref().map(|block| block.number))
2292                .unwrap_or(u64::MAX),
2293            transaction_index: log
2294                .transaction_index
2295                .or(record.context.transaction_index)
2296                .unwrap_or(u64::MAX),
2297            log_index: log
2298                .log_index
2299                .or(record.context.log_index)
2300                .unwrap_or(u64::MAX),
2301            original_index: index,
2302        };
2303    }
2304
2305    RecordSortKey {
2306        class: 1,
2307        block_number: 0,
2308        transaction_index: 0,
2309        log_index: 0,
2310        original_index: index,
2311    }
2312}
2313
2314#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
2315struct RecordSortKey {
2316    class: u8,
2317    block_number: u64,
2318    transaction_index: u64,
2319    log_index: u64,
2320    original_index: usize,
2321}
2322
2323fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
2324    match (interest, input) {
2325        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
2326        (
2327            ReactiveInterest::Blocks(BlockInterest {
2328                mode: BlockInterestMode::Header,
2329            }),
2330            ReactiveInput::BlockHeader(_),
2331        ) => true,
2332        (
2333            ReactiveInterest::Blocks(BlockInterest {
2334                mode: BlockInterestMode::FullBlock,
2335            }),
2336            ReactiveInput::FullBlock(_),
2337        ) => true,
2338        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
2339            interest.matches_hash_only()
2340        }
2341        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
2342            interest.matches_tx(tx)
2343        }
2344        _ => false,
2345    }
2346}
2347
2348fn validate_effects(
2349    input_ref: InputRef,
2350    ctx: &ReactiveContext,
2351    handler_id: &HandlerId,
2352    effects: &[ReactiveEffect],
2353) -> Result<(), ReactiveError> {
2354    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
2355        || matches!(input_ref, InputRef::PendingTx { .. });
2356    if !pending {
2357        return Ok(());
2358    }
2359
2360    for effect in effects {
2361        let effect_kind = match effect {
2362            ReactiveEffect::StateUpdate(_) => Some("state_update"),
2363            ReactiveEffect::Invalidate(_) => Some("invalidate"),
2364            ReactiveEffect::Resync(_) => Some("resync"),
2365            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
2366        };
2367        if let Some(effect_kind) = effect_kind {
2368            return Err(ReactiveError::InvalidPendingEffect {
2369                input_ref: Box::new(input_ref),
2370                handler_id: handler_id.clone(),
2371                effect_kind,
2372            });
2373        }
2374    }
2375    Ok(())
2376}
2377
2378fn detect_conflicts(
2379    input_ref: InputRef,
2380    executions: &[HandlerExecution],
2381) -> Result<(), ReactiveError> {
2382    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
2383    for execution in executions {
2384        for update in &execution.state_updates {
2385            for (target, value) in absolute_writes(update) {
2386                if let Some((previous_value, previous_handler)) = writes.get(&target) {
2387                    if previous_value != &value {
2388                        return Err(ReactiveError::ConflictingEffects {
2389                            input_ref: Box::new(input_ref),
2390                            target: Box::new(target),
2391                            first: previous_handler.clone(),
2392                            second: execution.handler_id.clone(),
2393                        });
2394                    }
2395                } else {
2396                    writes.insert(target, (value, execution.handler_id.clone()));
2397                }
2398            }
2399        }
2400    }
2401    Ok(())
2402}
2403
2404fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
2405    match update {
2406        StateUpdate::Slot {
2407            address,
2408            slot,
2409            value,
2410        } => vec![(
2411            EffectTarget::StorageSlot {
2412                address: *address,
2413                slot: *slot,
2414            },
2415            AbsoluteValue::U256(*value),
2416        )],
2417        StateUpdate::SlotMasked {
2418            address,
2419            slot,
2420            mask,
2421            value,
2422        } => vec![(
2423            EffectTarget::MaskedStorageSlot {
2424                address: *address,
2425                slot: *slot,
2426                mask: *mask,
2427            },
2428            AbsoluteValue::U256(*value),
2429        )],
2430        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
2431            account_patch_writes(*address, patch)
2432        }
2433        StateUpdate::SlotDelta { .. }
2434        | StateUpdate::BalanceDelta { .. }
2435        | StateUpdate::Purge { .. } => Vec::new(),
2436    }
2437}
2438
2439fn account_patch_writes(
2440    address: Address,
2441    patch: &AccountPatch,
2442) -> Vec<(EffectTarget, AbsoluteValue)> {
2443    let mut writes = Vec::new();
2444    if let Some(balance) = patch.balance {
2445        writes.push((
2446            EffectTarget::AccountBalance { address },
2447            AbsoluteValue::U256(balance),
2448        ));
2449    }
2450    if let Some(nonce) = patch.nonce {
2451        writes.push((
2452            EffectTarget::AccountNonce { address },
2453            AbsoluteValue::U64(nonce),
2454        ));
2455    }
2456    if let Some(code) = &patch.code {
2457        writes.push((
2458            EffectTarget::AccountCode { address },
2459            AbsoluteValue::Bytes(code.clone()),
2460        ));
2461    }
2462    writes
2463}
2464
2465fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
2466    match input {
2467        ReactiveInput::Log(log) => InputRef::Log {
2468            chain_id: ctx.chain_id,
2469            block_hash: log
2470                .block_hash
2471                .or(ctx.block.as_ref().map(|block| block.hash))
2472                .unwrap_or_default(),
2473            transaction_hash: log.transaction_hash.unwrap_or_default(),
2474            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
2475        },
2476        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
2477            chain_id: ctx.chain_id,
2478            hash: *hash,
2479        },
2480        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
2481            chain_id: ctx.chain_id,
2482            hash: tx.tx_hash(),
2483        },
2484        ReactiveInput::BlockHeader(header) => InputRef::Block {
2485            chain_id: ctx.chain_id,
2486            hash: header.hash(),
2487            number: header.number(),
2488        },
2489        ReactiveInput::FullBlock(block) => {
2490            let header = block.header();
2491            InputRef::Block {
2492                chain_id: ctx.chain_id,
2493                hash: header.hash(),
2494                number: header.number(),
2495            }
2496        }
2497    }
2498}
2499
2500fn is_canonical_status(status: &ChainStatus) -> bool {
2501    matches!(
2502        status,
2503        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
2504    )
2505}
2506
2507/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
2508pub struct EventDecoderHandler {
2509    id: HandlerId,
2510    decoder: Arc<dyn EventDecoder>,
2511    interest: LogInterest,
2512}
2513
2514impl EventDecoderHandler {
2515    /// Create an adapter from a decoder and log interest.
2516    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
2517        Self {
2518            id,
2519            decoder,
2520            interest,
2521        }
2522    }
2523}
2524
2525impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
2526    fn id(&self) -> HandlerId {
2527        self.id.clone()
2528    }
2529
2530    fn interests(&self) -> Vec<ReactiveInterest<N>> {
2531        vec![ReactiveInterest::Logs(self.interest.clone())]
2532    }
2533
2534    fn handle(
2535        &self,
2536        _ctx: &ReactiveContext,
2537        input: &ReactiveInput<N>,
2538        state: &dyn StateView,
2539    ) -> Result<HandlerOutcome, HandlerError> {
2540        let ReactiveInput::Log(log) = input else {
2541            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
2542        };
2543
2544        Ok(HandlerOutcome {
2545            effects: self
2546                .decoder
2547                .decode(&log.inner, state)
2548                .into_iter()
2549                .map(ReactiveEffect::StateUpdate)
2550                .collect(),
2551            quality: StateEffectQuality::ExactFromInput,
2552            tags: Vec::new(),
2553        })
2554    }
2555}
2556
2557/// Provider-agnostic subscriber interface.
2558pub trait EventSubscriber<N: Network = Ethereum>: Send {
2559    /// Register interests with the subscriber.
2560    fn register_interests(
2561        &mut self,
2562        interests: &[ReactiveInterest<N>],
2563    ) -> Result<(), SubscriberError>;
2564
2565    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
2566    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
2567}
2568
2569/// Boxed future returned by [`EventSubscriber::next_batch`].
2570pub type SubscriberNextBatch<'a, N> = Pin<
2571    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
2572>;
2573
2574/// Subscriber mode requested for the Alloy subscriber.
2575#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
2576pub enum SubscriberMode {
2577    /// Prefer the default compiled transport.
2578    ///
2579    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
2580    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
2581    /// the opt-in `reactive-polling` feature is enabled.
2582    #[default]
2583    Auto,
2584    /// Use provider pubsub streams.
2585    PubSub,
2586    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
2587    Polling,
2588}
2589
2590/// Subscriber configuration.
2591#[derive(Clone, Debug, PartialEq, Eq)]
2592pub struct SubscriberConfig {
2593    /// Hydrate pending transaction hashes into full bodies when possible.
2594    pub hydrate_pending_transactions: bool,
2595    /// Maximum records to emit per batch.
2596    pub max_batch_size: usize,
2597    /// Reconnect policy for WebSocket/pubsub streams.
2598    pub reconnect: SubscriberReconnectConfig,
2599}
2600
2601impl Default for SubscriberConfig {
2602    fn default() -> Self {
2603        Self {
2604            hydrate_pending_transactions: false,
2605            max_batch_size: 1024,
2606            reconnect: SubscriberReconnectConfig::default(),
2607        }
2608    }
2609}
2610
2611/// WebSocket/pubsub reconnect policy.
2612///
2613/// Reconnects are applied after an established subscription stream terminates.
2614/// Initial subscription failures are still returned immediately so deployment
2615/// mistakes, unsupported transports, and bad endpoints fail fast.
2616#[derive(Clone, Debug, PartialEq, Eq)]
2617pub struct SubscriberReconnectConfig {
2618    /// Whether pubsub streams should be recreated after termination.
2619    pub enabled: bool,
2620    /// Delay before the first reconnect attempt.
2621    pub initial_delay: Duration,
2622    /// Delay before the second reconnect attempt. Later retries double this
2623    /// delay up to [`Self::max_delay`].
2624    pub retry_delay: Duration,
2625    /// Maximum delay between reconnect attempts.
2626    pub max_delay: Duration,
2627    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
2628    pub max_attempts: Option<usize>,
2629    /// Number of recently emitted canonical input refs remembered to suppress
2630    /// duplicates across reconnect backfill and subscription replay.
2631    pub dedupe_window: usize,
2632}
2633
2634impl Default for SubscriberReconnectConfig {
2635    fn default() -> Self {
2636        Self {
2637            enabled: true,
2638            initial_delay: Duration::ZERO,
2639            retry_delay: Duration::from_millis(250),
2640            max_delay: Duration::from_secs(30),
2641            max_attempts: Some(3),
2642            dedupe_window: 4096,
2643        }
2644    }
2645}
2646
2647/// Alloy-backed event subscriber.
2648///
2649/// The default transport slice drives Alloy pubsub subscriptions for logs,
2650/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
2651/// transport remains available behind the opt-in `reactive-polling` feature.
2652/// Pubsub streams reconnect automatically after termination, and log
2653/// subscriptions are backfilled from the last seen block. Full pending
2654/// transaction hydration, full block bodies, and arbitrary historical backfill
2655/// remain explicit follow-up work.
2656/// With no registered interests, [`EventSubscriber::next_batch`] returns
2657/// `Ok(None)`.
2658pub struct AlloySubscriber<P, N: Network = Ethereum> {
2659    provider: P,
2660    mode: SubscriberMode,
2661    config: SubscriberConfig,
2662    interests: Vec<ReactiveInterest<N>>,
2663    state: AlloySubscriberState<N>,
2664    pending_records: VecDeque<ReactiveInputRecord<N>>,
2665    last_seen_log_blocks: HashMap<usize, u64>,
2666    recent_input_refs: VecDeque<InputRef>,
2667    recent_input_ref_set: HashSet<InputRef>,
2668    _network: PhantomData<N>,
2669}
2670
2671/// Best-effort installation of rustls' `ring` crypto provider as the process
2672/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
2673/// "no process-level CryptoProvider available". Runs at most once and ignores the
2674/// error if a default provider is already installed (the host app may have set
2675/// its own).
2676#[cfg(feature = "reactive-ws")]
2677fn ensure_ring_crypto_provider() {
2678    use std::sync::Once;
2679    static INSTALL: Once = Once::new();
2680    INSTALL.call_once(|| {
2681        let _ = rustls::crypto::ring::default_provider().install_default();
2682    });
2683}
2684
2685impl<P, N: Network> AlloySubscriber<P, N> {
2686    /// Create a new Alloy subscriber.
2687    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
2688        #[cfg(feature = "reactive-ws")]
2689        ensure_ring_crypto_provider();
2690        Self {
2691            provider,
2692            mode,
2693            config,
2694            interests: Vec::new(),
2695            state: AlloySubscriberState::Uninitialized,
2696            pending_records: VecDeque::new(),
2697            last_seen_log_blocks: HashMap::new(),
2698            recent_input_refs: VecDeque::new(),
2699            recent_input_ref_set: HashSet::new(),
2700            _network: PhantomData,
2701        }
2702    }
2703
2704    /// Borrow the provider.
2705    pub fn provider(&self) -> &P {
2706        &self.provider
2707    }
2708
2709    /// Subscriber mode.
2710    pub fn mode(&self) -> SubscriberMode {
2711        self.mode
2712    }
2713
2714    /// Subscriber config.
2715    pub fn config(&self) -> &SubscriberConfig {
2716        &self.config
2717    }
2718
2719    /// Registered interests.
2720    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
2721        &self.interests
2722    }
2723
2724    fn drain_next_batch(&mut self) -> Option<ReactiveInputBatch<N>> {
2725        if self.pending_records.is_empty() {
2726            return None;
2727        }
2728
2729        let len = self.config.max_batch_size.min(self.pending_records.len());
2730        let records = self.pending_records.drain(..len).collect();
2731        Some(ReactiveInputBatch::new(records))
2732    }
2733
2734    fn reset_delivery_state(&mut self) {
2735        self.pending_records.clear();
2736        self.last_seen_log_blocks.clear();
2737        self.recent_input_refs.clear();
2738        self.recent_input_ref_set.clear();
2739    }
2740}
2741
2742enum AlloySubscriberState<N: Network> {
2743    Uninitialized,
2744    Active(SubscriberStreams<N>),
2745    Empty,
2746}
2747
2748struct SubscriberStreams<N: Network> {
2749    streams: SelectAll<BoxStream<'static, SubscriberEvent<N>>>,
2750}
2751
2752impl<N: Network> SubscriberStreams<N> {
2753    fn new() -> Self {
2754        Self {
2755            streams: SelectAll::new(),
2756        }
2757    }
2758
2759    fn is_empty(&self) -> bool {
2760        self.streams.is_empty()
2761    }
2762
2763    fn push(&mut self, stream: BoxStream<'static, SubscriberEvent<N>>) {
2764        self.streams.push(stream);
2765    }
2766
2767    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
2768        self.streams.next().await
2769    }
2770}
2771
2772#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2773#[allow(dead_code)]
2774enum SubscriberTransport {
2775    PubSub,
2776    Polling,
2777}
2778
2779#[derive(Clone, Debug)]
2780enum SubscriberStreamSource {
2781    PubSubLog { id: usize, filter: Filter },
2782    PubSubPendingHashes,
2783    PubSubBlockHeaders,
2784    PollingLog { filter: Filter },
2785    PollingPendingHashes,
2786}
2787
2788impl SubscriberStreamSource {
2789    fn label(&self) -> &'static str {
2790        match self {
2791            Self::PubSubLog { .. } => "pubsub log",
2792            Self::PubSubPendingHashes => "pubsub pending transaction hash",
2793            Self::PubSubBlockHeaders => "pubsub block header",
2794            Self::PollingLog { .. } => "polling log",
2795            Self::PollingPendingHashes => "polling pending transaction hash",
2796        }
2797    }
2798
2799    fn is_pubsub(&self) -> bool {
2800        matches!(
2801            self,
2802            Self::PubSubLog { .. } | Self::PubSubPendingHashes | Self::PubSubBlockHeaders
2803        )
2804    }
2805}
2806
2807#[allow(dead_code)]
2808enum SubscriberEvent<N: Network> {
2809    Log { source_id: usize, log: Log },
2810    BackfilledLogs { source_id: usize, logs: Vec<Log> },
2811    Logs(Vec<Log>),
2812    BlockHeader(N::HeaderResponse),
2813    PendingHash(B256),
2814    PendingHashes(Vec<B256>),
2815    StreamTerminated(SubscriberStreamSource),
2816}
2817
2818impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
2819where
2820    P: Provider<N> + Send + Sync,
2821    N: Network + 'static,
2822    N::HeaderResponse: Send + 'static,
2823{
2824    fn register_interests(
2825        &mut self,
2826        interests: &[ReactiveInterest<N>],
2827    ) -> Result<(), SubscriberError> {
2828        validate_subscriber_config(&self.config)?;
2829        validate_supported_interests(self.mode, &self.config, interests)?;
2830
2831        self.interests = interests.to_vec();
2832        self.reset_delivery_state();
2833        self.state = AlloySubscriberState::Uninitialized;
2834        Ok(())
2835    }
2836
2837    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
2838        Box::pin(async {
2839            if let Some(batch) = self.drain_next_batch() {
2840                return Ok(Some(batch));
2841            }
2842
2843            if self.interests.is_empty() {
2844                return Ok(None);
2845            }
2846
2847            if matches!(self.state, AlloySubscriberState::Uninitialized) {
2848                let streams = self.init_streams().await?;
2849                self.state = if streams.is_empty() {
2850                    AlloySubscriberState::Empty
2851                } else {
2852                    AlloySubscriberState::Active(streams)
2853                };
2854            }
2855
2856            loop {
2857                let Some(event) = self.next_event().await? else {
2858                    return Ok(None);
2859                };
2860
2861                self.enqueue_event(event);
2862                if let Some(batch) = self.drain_next_batch() {
2863                    return Ok(Some(batch));
2864                }
2865            }
2866        })
2867    }
2868}
2869
2870impl<P, N> AlloySubscriber<P, N>
2871where
2872    P: Provider<N> + Send + Sync,
2873    N: Network + 'static,
2874    N::HeaderResponse: Send + 'static,
2875{
2876    async fn init_streams(&mut self) -> Result<SubscriberStreams<N>, SubscriberError> {
2877        let mut streams = SubscriberStreams::new();
2878        for source in self.stream_sources()? {
2879            streams.push(self.connect_source_stream(source).await?);
2880        }
2881        Ok(streams)
2882    }
2883
2884    fn stream_sources(&self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
2885        match resolve_subscriber_transport(self.mode)? {
2886            SubscriberTransport::PubSub => Ok(pubsub_stream_sources(&self.interests)),
2887            SubscriberTransport::Polling => Ok(polling_stream_sources(&self.interests)),
2888        }
2889    }
2890
2891    async fn connect_source_stream(
2892        &mut self,
2893        source: SubscriberStreamSource,
2894    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
2895        match source {
2896            SubscriberStreamSource::PubSubLog { id, filter } => {
2897                self.connect_pubsub_log_stream(id, filter).await
2898            }
2899            SubscriberStreamSource::PubSubPendingHashes => {
2900                self.connect_pubsub_pending_hash_stream().await
2901            }
2902            SubscriberStreamSource::PubSubBlockHeaders => {
2903                self.connect_pubsub_block_header_stream().await
2904            }
2905            SubscriberStreamSource::PollingLog { filter } => {
2906                self.connect_polling_log_stream(filter).await
2907            }
2908            SubscriberStreamSource::PollingPendingHashes => {
2909                self.connect_polling_pending_hash_stream().await
2910            }
2911        }
2912    }
2913
2914    async fn connect_pubsub_log_stream(
2915        &mut self,
2916        id: usize,
2917        filter: Filter,
2918    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
2919        #[cfg(feature = "reactive-ws")]
2920        {
2921            let source = SubscriberStreamSource::PubSubLog {
2922                id,
2923                filter: filter.clone(),
2924            };
2925            let stream = self
2926                .provider
2927                .subscribe_logs(&filter)
2928                .channel_size(self.config.max_batch_size.max(1))
2929                .await
2930                .map_err(provider_error)?
2931                .into_stream()
2932                .map(move |log| SubscriberEvent::Log { source_id: id, log });
2933            Ok(stream_with_termination(stream, source))
2934        }
2935
2936        #[cfg(not(feature = "reactive-ws"))]
2937        {
2938            let _ = (id, filter);
2939            Err(SubscriberError::Unsupported(
2940                "AlloySubscriber pubsub mode requires the reactive-ws feature",
2941            ))
2942        }
2943    }
2944
2945    async fn connect_pubsub_pending_hash_stream(
2946        &mut self,
2947    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
2948        #[cfg(feature = "reactive-ws")]
2949        {
2950            let stream = self
2951                .provider
2952                .subscribe_pending_transactions()
2953                .channel_size(self.config.max_batch_size.max(1))
2954                .await
2955                .map_err(provider_error)?
2956                .into_stream()
2957                .map(SubscriberEvent::PendingHash);
2958            Ok(stream_with_termination(
2959                stream,
2960                SubscriberStreamSource::PubSubPendingHashes,
2961            ))
2962        }
2963
2964        #[cfg(not(feature = "reactive-ws"))]
2965        {
2966            Err(SubscriberError::Unsupported(
2967                "AlloySubscriber pubsub mode requires the reactive-ws feature",
2968            ))
2969        }
2970    }
2971
2972    async fn connect_pubsub_block_header_stream(
2973        &mut self,
2974    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
2975        #[cfg(feature = "reactive-ws")]
2976        {
2977            let stream = self
2978                .provider
2979                .subscribe_blocks()
2980                .channel_size(self.config.max_batch_size.max(1))
2981                .await
2982                .map_err(provider_error)?
2983                .into_stream()
2984                .map(SubscriberEvent::BlockHeader);
2985            Ok(stream_with_termination(
2986                stream,
2987                SubscriberStreamSource::PubSubBlockHeaders,
2988            ))
2989        }
2990
2991        #[cfg(not(feature = "reactive-ws"))]
2992        {
2993            Err(SubscriberError::Unsupported(
2994                "AlloySubscriber pubsub mode requires the reactive-ws feature",
2995            ))
2996        }
2997    }
2998
2999    async fn connect_polling_log_stream(
3000        &mut self,
3001        filter: Filter,
3002    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
3003        #[cfg(feature = "reactive-polling")]
3004        {
3005            let source = SubscriberStreamSource::PollingLog {
3006                filter: filter.clone(),
3007            };
3008            let stream = self
3009                .provider
3010                .watch_logs(&filter)
3011                .await
3012                .map_err(provider_error)?
3013                .with_channel_size(self.config.max_batch_size.max(1))
3014                .into_stream()
3015                .map(SubscriberEvent::Logs);
3016            Ok(stream_with_termination(stream, source))
3017        }
3018
3019        #[cfg(not(feature = "reactive-polling"))]
3020        {
3021            let _ = filter;
3022            Err(SubscriberError::Unsupported(
3023                "AlloySubscriber polling mode requires the reactive-polling feature",
3024            ))
3025        }
3026    }
3027
3028    async fn connect_polling_pending_hash_stream(
3029        &mut self,
3030    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
3031        #[cfg(feature = "reactive-polling")]
3032        {
3033            let stream = self
3034                .provider
3035                .watch_pending_transactions()
3036                .await
3037                .map_err(provider_error)?
3038                .with_channel_size(self.config.max_batch_size.max(1))
3039                .into_stream()
3040                .map(SubscriberEvent::PendingHashes);
3041            Ok(stream_with_termination(
3042                stream,
3043                SubscriberStreamSource::PollingPendingHashes,
3044            ))
3045        }
3046
3047        #[cfg(not(feature = "reactive-polling"))]
3048        {
3049            Err(SubscriberError::Unsupported(
3050                "AlloySubscriber polling mode requires the reactive-polling feature",
3051            ))
3052        }
3053    }
3054
3055    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
3056        loop {
3057            let event = match &mut self.state {
3058                AlloySubscriberState::Active(streams) => streams.next().await,
3059                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
3060                    return Ok(None);
3061                }
3062            };
3063
3064            let Some(event) = event else {
3065                return Err(SubscriberError::Provider(
3066                    "Alloy subscriber streams terminated before the subscriber was stopped"
3067                        .to_owned(),
3068                ));
3069            };
3070
3071            match event {
3072                SubscriberEvent::StreamTerminated(source) => {
3073                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
3074                        return Ok(Some(backfill_event));
3075                    }
3076                }
3077                event => return Ok(Some(event)),
3078            }
3079        }
3080    }
3081
3082    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
3083        match event {
3084            SubscriberEvent::Log { source_id, log } => {
3085                if log_matches_any_interest(&log, &self.interests) {
3086                    let record = log_input_record(log, InputSource::Subscription);
3087                    self.note_log_block(source_id, &record);
3088                    self.enqueue_record(record);
3089                }
3090            }
3091            SubscriberEvent::BackfilledLogs { source_id, logs } => {
3092                for log in logs {
3093                    if log_matches_any_interest(&log, &self.interests) {
3094                        let record = log_input_record(log, InputSource::Backfill);
3095                        self.note_log_block(source_id, &record);
3096                        self.enqueue_record(record);
3097                    }
3098                }
3099            }
3100            SubscriberEvent::Logs(logs) => self.pending_records.extend(
3101                logs.into_iter()
3102                    .filter(|log| log_matches_any_interest(log, &self.interests))
3103                    .map(|log| log_input_record(log, InputSource::Poll)),
3104            ),
3105            SubscriberEvent::BlockHeader(header) => {
3106                if needs_header_block_stream(&self.interests) {
3107                    let record = block_header_input_record::<N>(header);
3108                    self.enqueue_record(record);
3109                }
3110            }
3111            SubscriberEvent::PendingHash(hash) => {
3112                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
3113                self.enqueue_record(record);
3114            }
3115            SubscriberEvent::PendingHashes(hashes) => self.pending_records.extend(
3116                hashes
3117                    .into_iter()
3118                    .map(|hash| pending_hash_input_record::<N>(hash, InputSource::Poll)),
3119            ),
3120            SubscriberEvent::StreamTerminated(_) => {}
3121        }
3122    }
3123
3124    async fn reconnect_source_stream(
3125        &mut self,
3126        source: SubscriberStreamSource,
3127    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
3128        if !source.is_pubsub() {
3129            return Err(stream_terminated_error(&source));
3130        }
3131
3132        if !self.config.reconnect.enabled {
3133            return Err(SubscriberError::Provider(format!(
3134                "Alloy subscriber {} stream terminated and reconnect is disabled",
3135                source.label()
3136            )));
3137        }
3138
3139        let mut attempts = 0usize;
3140        let mut delay = self.config.reconnect.initial_delay;
3141        let mut retry_delay = self.config.reconnect.retry_delay;
3142
3143        loop {
3144            attempts = attempts.saturating_add(1);
3145            if !delay.is_zero() {
3146                tokio::time::sleep(delay).await;
3147            }
3148
3149            match self.reconnect_source_once(source.clone()).await {
3150                Ok(backfill_event) => return Ok(backfill_event),
3151                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
3152                    return Err(SubscriberError::Provider(format!(
3153                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
3154                        source.label()
3155                    )));
3156                }
3157                Err(error) => {
3158                    tracing::warn!(
3159                        stream = source.label(),
3160                        attempts,
3161                        error = %error,
3162                        "Alloy subscriber reconnect attempt failed"
3163                    );
3164                    delay = retry_delay;
3165                    retry_delay =
3166                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
3167                }
3168            }
3169        }
3170    }
3171
3172    async fn reconnect_source_once(
3173        &mut self,
3174        source: SubscriberStreamSource,
3175    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
3176        let stream = self.connect_source_stream(source.clone()).await?;
3177        let backfill_event = self.backfill_reconnected_source(&source).await?;
3178
3179        match &mut self.state {
3180            AlloySubscriberState::Active(streams) => streams.push(stream),
3181            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
3182                return Err(SubscriberError::Provider(
3183                    "Alloy subscriber state changed before reconnect completed".to_owned(),
3184                ));
3185            }
3186        }
3187
3188        Ok(backfill_event)
3189    }
3190
3191    async fn backfill_reconnected_source(
3192        &mut self,
3193        source: &SubscriberStreamSource,
3194    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
3195        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
3196            return Ok(None);
3197        };
3198        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
3199            return Ok(None);
3200        };
3201
3202        let latest = self
3203            .provider
3204            .get_block_number()
3205            .await
3206            .map_err(provider_error)?;
3207        if latest < from_block {
3208            return Ok(None);
3209        }
3210
3211        let logs = self
3212            .provider
3213            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
3214            .await
3215            .map_err(provider_error)?;
3216        Ok(Some(SubscriberEvent::BackfilledLogs {
3217            source_id: *id,
3218            logs,
3219        }))
3220    }
3221
3222    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
3223        if let Some(block) = record.context.block.as_ref() {
3224            self.last_seen_log_blocks.insert(source_id, block.number);
3225        }
3226    }
3227
3228    fn enqueue_record(&mut self, record: ReactiveInputRecord<N>) {
3229        if self.should_skip_recent_duplicate(&record) {
3230            return;
3231        }
3232        self.remember_record(&record);
3233        self.pending_records.push_back(record);
3234    }
3235
3236    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
3237        if !should_dedupe_record(record) {
3238            return false;
3239        }
3240        self.recent_input_ref_set.contains(&record.input_ref())
3241    }
3242
3243    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
3244        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
3245            return;
3246        }
3247
3248        let input_ref = record.input_ref();
3249        if !self.recent_input_ref_set.insert(input_ref) {
3250            return;
3251        }
3252        self.recent_input_refs.push_back(input_ref);
3253
3254        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
3255            if let Some(evicted) = self.recent_input_refs.pop_front() {
3256                self.recent_input_ref_set.remove(&evicted);
3257            }
3258        }
3259    }
3260}
3261
3262fn stream_with_termination<N, S>(
3263    stream: S,
3264    source: SubscriberStreamSource,
3265) -> BoxStream<'static, SubscriberEvent<N>>
3266where
3267    N: Network + 'static,
3268    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
3269{
3270    stream
3271        .chain(stream::once(async move {
3272            SubscriberEvent::StreamTerminated(source)
3273        }))
3274        .boxed()
3275}
3276
3277fn pubsub_stream_sources<N: Network>(
3278    interests: &[ReactiveInterest<N>],
3279) -> Vec<SubscriberStreamSource> {
3280    let mut sources = Vec::new();
3281
3282    for (id, filter) in log_filters(interests).into_iter().enumerate() {
3283        sources.push(SubscriberStreamSource::PubSubLog { id, filter });
3284    }
3285
3286    if needs_pending_hash_stream(interests) {
3287        sources.push(SubscriberStreamSource::PubSubPendingHashes);
3288    }
3289
3290    if needs_header_block_stream(interests) {
3291        sources.push(SubscriberStreamSource::PubSubBlockHeaders);
3292    }
3293
3294    sources
3295}
3296
3297fn polling_stream_sources<N: Network>(
3298    interests: &[ReactiveInterest<N>],
3299) -> Vec<SubscriberStreamSource> {
3300    let mut sources = Vec::new();
3301
3302    for filter in log_filters(interests) {
3303        sources.push(SubscriberStreamSource::PollingLog { filter });
3304    }
3305
3306    if needs_pending_hash_stream(interests) {
3307        sources.push(SubscriberStreamSource::PollingPendingHashes);
3308    }
3309
3310    sources
3311}
3312
3313fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
3314    SubscriberError::Provider(format!(
3315        "Alloy subscriber {} stream terminated before the subscriber was stopped",
3316        source.label()
3317    ))
3318}
3319
3320fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
3321    config
3322        .max_attempts
3323        .is_some_and(|max_attempts| attempts >= max_attempts)
3324}
3325
3326fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
3327    if current.is_zero() {
3328        return current;
3329    }
3330    current.checked_mul(2).unwrap_or(max).min(max)
3331}
3332
3333fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
3334    match &record.input {
3335        ReactiveInput::Log(log) => {
3336            is_canonical_status(&record.context.chain_status) && !log.removed
3337        }
3338        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
3339        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
3340    }
3341}
3342
3343#[cfg(test)]
3344mod subscriber_helper_tests {
3345    use super::*;
3346    use alloy_provider::ProviderBuilder;
3347    use alloy_transport::mock::Asserter;
3348
3349    fn rpc_log(removed: bool) -> Log {
3350        Log {
3351            inner: alloy_primitives::Log::new_unchecked(
3352                Address::repeat_byte(0x42),
3353                vec![B256::repeat_byte(0x01)],
3354                Bytes::new(),
3355            ),
3356            block_hash: Some(B256::repeat_byte(0x02)),
3357            block_number: Some(7),
3358            block_timestamp: Some(1_700_000_000),
3359            transaction_hash: Some(B256::repeat_byte(0x03)),
3360            transaction_index: Some(4),
3361            log_index: Some(5),
3362            removed,
3363        }
3364    }
3365
3366    #[tokio::test(flavor = "multi_thread")]
3367    async fn stream_with_termination_yields_terminal_source_marker() {
3368        let mut stream = stream_with_termination::<Ethereum, _>(
3369            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
3370                0xaa,
3371            ))]),
3372            SubscriberStreamSource::PubSubPendingHashes,
3373        );
3374
3375        assert!(matches!(
3376            stream.next().await,
3377            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
3378        ));
3379        assert!(matches!(
3380            stream.next().await,
3381            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
3382        ));
3383        assert!(stream.next().await.is_none());
3384    }
3385
3386    #[test]
3387    fn reconnect_delay_doubles_until_capped() {
3388        assert_eq!(
3389            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
3390            Duration::from_millis(500)
3391        );
3392        assert_eq!(
3393            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
3394            Duration::from_secs(1)
3395        );
3396        assert_eq!(
3397            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
3398            Duration::ZERO
3399        );
3400    }
3401
3402    #[test]
3403    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
3404        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
3405        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
3406
3407        assert!(should_dedupe_record(&included));
3408        assert!(!should_dedupe_record(&removed));
3409    }
3410
3411    #[test]
3412    fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
3413        let sources = pubsub_stream_sources::<Ethereum>(&[
3414            ReactiveInterest::Logs(LogInterest {
3415                provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
3416                local_matcher: None,
3417                route_key: None,
3418            }),
3419            ReactiveInterest::Logs(LogInterest {
3420                provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
3421                local_matcher: None,
3422                route_key: None,
3423            }),
3424            ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
3425        ]);
3426
3427        assert!(matches!(
3428            &sources[0],
3429            SubscriberStreamSource::PubSubLog { id: 0, .. }
3430        ));
3431        assert!(matches!(
3432            sources[1],
3433            SubscriberStreamSource::PubSubPendingHashes
3434        ));
3435    }
3436
3437    #[tokio::test(flavor = "multi_thread")]
3438    #[cfg(feature = "reactive-ws")]
3439    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
3440        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
3441        let mut subscriber = AlloySubscriber::new(
3442            provider,
3443            SubscriberMode::PubSub,
3444            SubscriberConfig {
3445                reconnect: SubscriberReconnectConfig {
3446                    initial_delay: Duration::ZERO,
3447                    retry_delay: Duration::ZERO,
3448                    max_delay: Duration::ZERO,
3449                    max_attempts: Some(1),
3450                    ..SubscriberReconnectConfig::default()
3451                },
3452                ..SubscriberConfig::default()
3453            },
3454        );
3455        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
3456            PendingTxInterest::default(),
3457        )];
3458
3459        let mut streams = SubscriberStreams::new();
3460        streams.push(
3461            stream::once(async {
3462                SubscriberEvent::<Ethereum>::StreamTerminated(
3463                    SubscriberStreamSource::PubSubPendingHashes,
3464                )
3465            })
3466            .boxed(),
3467        );
3468        subscriber.state = AlloySubscriberState::Active(streams);
3469
3470        let result = subscriber.next_batch().await;
3471        assert!(
3472            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
3473            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
3474        );
3475    }
3476
3477    #[test]
3478    fn backfilled_logs_skip_recent_subscription_duplicates() {
3479        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
3480        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
3481            provider,
3482            SubscriberMode::PubSub,
3483            SubscriberConfig::default(),
3484        );
3485        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
3486            provider_filter: Filter::new()
3487                .address(Address::repeat_byte(0x42))
3488                .event_signature(B256::repeat_byte(0x01)),
3489            local_matcher: None,
3490            route_key: None,
3491        })];
3492
3493        let log = rpc_log(false);
3494        subscriber.enqueue_event(SubscriberEvent::Log {
3495            source_id: 0,
3496            log: log.clone(),
3497        });
3498        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
3499            source_id: 0,
3500            logs: vec![log],
3501        });
3502
3503        assert_eq!(subscriber.pending_records.len(), 1);
3504        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
3505        assert_eq!(
3506            subscriber.pending_records[0].context.source,
3507            InputSource::Subscription
3508        );
3509    }
3510
3511    #[test]
3512    fn backfilled_logs_surface_with_backfill_source() {
3513        // A backfilled log with no prior subscription duplicate is delivered as
3514        // an `InputSource::Backfill` record (the positive side of the dedup test,
3515        // pinning the README's "marking recovered records as InputSource::Backfill"
3516        // claim — the only place that source is produced).
3517        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
3518        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
3519            provider,
3520            SubscriberMode::PubSub,
3521            SubscriberConfig::default(),
3522        );
3523        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
3524            provider_filter: Filter::new()
3525                .address(Address::repeat_byte(0x42))
3526                .event_signature(B256::repeat_byte(0x01)),
3527            local_matcher: None,
3528            route_key: None,
3529        })];
3530
3531        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
3532            source_id: 0,
3533            logs: vec![rpc_log(false)],
3534        });
3535
3536        assert_eq!(subscriber.pending_records.len(), 1);
3537        assert_eq!(
3538            subscriber.pending_records[0].context.source,
3539            InputSource::Backfill
3540        );
3541        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
3542    }
3543}
3544
3545fn resolve_subscriber_transport(
3546    mode: SubscriberMode,
3547) -> Result<SubscriberTransport, SubscriberError> {
3548    match mode {
3549        SubscriberMode::PubSub => {
3550            #[cfg(feature = "reactive-ws")]
3551            {
3552                Ok(SubscriberTransport::PubSub)
3553            }
3554            #[cfg(not(feature = "reactive-ws"))]
3555            {
3556                Err(SubscriberError::Unsupported(
3557                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
3558                ))
3559            }
3560        }
3561        SubscriberMode::Polling => {
3562            #[cfg(feature = "reactive-polling")]
3563            {
3564                Ok(SubscriberTransport::Polling)
3565            }
3566            #[cfg(not(feature = "reactive-polling"))]
3567            {
3568                Err(SubscriberError::Unsupported(
3569                    "AlloySubscriber polling mode requires the reactive-polling feature",
3570                ))
3571            }
3572        }
3573        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
3574    }
3575}
3576
3577fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
3578    #[cfg(feature = "reactive-ws")]
3579    {
3580        Ok(SubscriberTransport::PubSub)
3581    }
3582
3583    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
3584    {
3585        Ok(SubscriberTransport::Polling)
3586    }
3587
3588    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
3589    {
3590        Err(SubscriberError::Unsupported(
3591            "AlloySubscriber requires either reactive-ws or reactive-polling",
3592        ))
3593    }
3594}
3595
3596fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
3597    if config.max_batch_size == 0 {
3598        return Err(SubscriberError::InvalidConfig(
3599            "SubscriberConfig::max_batch_size must be greater than zero",
3600        ));
3601    }
3602    if config.reconnect.enabled {
3603        if config.reconnect.retry_delay > config.reconnect.max_delay {
3604            return Err(SubscriberError::InvalidConfig(
3605                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
3606            ));
3607        }
3608        if matches!(config.reconnect.max_attempts, Some(0)) {
3609            return Err(SubscriberError::InvalidConfig(
3610                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
3611            ));
3612        }
3613    }
3614    Ok(())
3615}
3616
3617fn validate_supported_interests<N: Network>(
3618    mode: SubscriberMode,
3619    config: &SubscriberConfig,
3620    interests: &[ReactiveInterest<N>],
3621) -> Result<(), SubscriberError> {
3622    let transport = resolve_subscriber_transport(mode)?;
3623
3624    for interest in interests {
3625        match interest {
3626            ReactiveInterest::Logs(_) => {}
3627            ReactiveInterest::PendingTransactions(interest)
3628                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
3629            ReactiveInterest::PendingTransactions(_) => {
3630                return Err(SubscriberError::Unsupported(
3631                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
3632                ));
3633            }
3634            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
3635                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
3636                (_, BlockInterestMode::FullBlock) => {
3637                    return Err(SubscriberError::Unsupported(
3638                        "AlloySubscriber full block streams are not implemented in this transport slice",
3639                    ));
3640                }
3641                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
3642                    return Err(SubscriberError::Unsupported(
3643                        "AlloySubscriber polling block streams are not implemented in this transport slice",
3644                    ));
3645                }
3646            },
3647        }
3648    }
3649
3650    Ok(())
3651}
3652
3653fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
3654    let mut filters = Vec::new();
3655    for interest in interests {
3656        if let ReactiveInterest::Logs(interest) = interest {
3657            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
3658        }
3659    }
3660    filters
3661}
3662
3663fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
3664    interests.iter().any(|interest| {
3665        matches!(
3666            interest,
3667            ReactiveInterest::Blocks(BlockInterest {
3668                mode: BlockInterestMode::Header,
3669            })
3670        )
3671    })
3672}
3673
3674fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
3675    interests.iter().any(|interest| {
3676        matches!(
3677            interest,
3678            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
3679        )
3680    })
3681}
3682
3683fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
3684    interests.iter().any(|interest| {
3685        matches!(
3686            interest,
3687            ReactiveInterest::Logs(interest) if interest.matches(log)
3688        )
3689    })
3690}
3691
3692fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
3693    let context = log_reactive_context(&log);
3694    ReactiveInputRecord::new(
3695        ReactiveInput::Log(log),
3696        ReactiveContext { source, ..context },
3697    )
3698}
3699
3700fn log_reactive_context(log: &Log) -> ReactiveContext {
3701    let block = match (log.block_hash, log.block_number) {
3702        (Some(hash), Some(number)) => Some(BlockRef {
3703            number,
3704            hash,
3705            parent_hash: None,
3706            timestamp: log.block_timestamp,
3707        }),
3708        _ => None,
3709    };
3710
3711    let chain_status = match (&block, log.removed) {
3712        (Some(block), true) => ChainStatus::Reorged {
3713            dropped_from: block.clone(),
3714        },
3715        (Some(block), false) => ChainStatus::Included {
3716            block: block.clone(),
3717            confirmations: 0,
3718        },
3719        (None, _) => ChainStatus::Pending,
3720    };
3721
3722    ReactiveContext {
3723        chain_id: None,
3724        source: InputSource::Poll,
3725        chain_status,
3726        block,
3727        transaction_index: log.transaction_index,
3728        log_index: log.log_index,
3729    }
3730}
3731
3732fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
3733where
3734    N: Network,
3735{
3736    let block = BlockRef {
3737        number: header.number(),
3738        hash: HeaderResponseTrait::hash(&header),
3739        parent_hash: Some(header.parent_hash()),
3740        timestamp: Some(header.timestamp()),
3741    };
3742    ReactiveInputRecord::new(
3743        ReactiveInput::BlockHeader(header),
3744        ReactiveContext {
3745            chain_id: None,
3746            source: InputSource::Subscription,
3747            chain_status: ChainStatus::Included {
3748                block: block.clone(),
3749                confirmations: 0,
3750            },
3751            block: Some(block),
3752            transaction_index: None,
3753            log_index: None,
3754        },
3755    )
3756}
3757
3758fn pending_hash_input_record<N: Network>(
3759    hash: B256,
3760    source: InputSource,
3761) -> ReactiveInputRecord<N> {
3762    ReactiveInputRecord::new(
3763        ReactiveInput::PendingTxHash(hash),
3764        ReactiveContext {
3765            chain_id: None,
3766            source,
3767            chain_status: ChainStatus::Pending,
3768            block: None,
3769            transaction_index: None,
3770            log_index: None,
3771        },
3772    )
3773}
3774
3775fn provider_error(error: impl fmt::Display) -> SubscriberError {
3776    SubscriberError::Provider(error.to_string())
3777}
3778
3779/// Subscriber error.
3780#[derive(Debug, thiserror::Error)]
3781pub enum SubscriberError {
3782    /// Invalid subscriber configuration.
3783    #[error("{0}")]
3784    InvalidConfig(&'static str),
3785    /// Requested subscriber behavior is not implemented.
3786    #[error("{0}")]
3787    Unsupported(&'static str),
3788    /// Provider or transport error.
3789    #[error("provider error: {0}")]
3790    Provider(String),
3791}