Skip to main content

evm_fork_cache/reactive/
mod.rs

1//! Protocol-neutral reactive runtime for cache state effects.
2//!
3//! The reactive runtime generalizes the log-only [`events`](crate::events)
4//! pipeline into a handler pipeline that can ingest logs, block notifications,
5//! and pending transaction signals. Handlers remain pure synchronous functions:
6//! they read through [`StateView`], return structured
7//! [`ReactiveEffect`] values, and let the runtime validate and commit cache
8//! mutations through [`StateUpdate`].
9//!
10//! This module intentionally contains no protocol, AMM, strategy, signing, or
11//! transaction-submission concepts. Downstream crates can layer those domains on
12//! top by implementing [`ReactiveHandler`] and [`ReactiveHook`].
13
14use std::{
15    any::Any,
16    borrow::Cow,
17    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    num::NonZeroU64,
23    path::PathBuf,
24    pin::Pin,
25    sync::{
26        Arc,
27        atomic::{AtomicU64, Ordering},
28    },
29    time::Duration,
30};
31
32use alloy_consensus::{BlockHeader as _, Transaction as _};
33use alloy_eips::{BlockId, BlockNumberOrTag};
34use alloy_network::{
35    Ethereum, Network,
36    primitives::{
37        BlockResponse as _, HeaderResponse as HeaderResponseTrait,
38        TransactionResponse as TransactionResponseTrait,
39    },
40};
41use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256};
42use alloy_provider::Provider;
43use alloy_rpc_types_eth::{Filter, FilterSet, Log};
44pub use alloy_transport_balancer::EndpointId;
45use bincode::Options;
46use futures::{StreamExt, stream};
47use futures::{
48    future::{Either, poll_fn, select},
49    stream::BoxStream,
50};
51
52use crate::{
53    cache::{
54        AccountProof, BlockStateDiff, DurableCheckpointBlock, DurableCheckpointError,
55        DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache,
56        EvmCacheStateSnapshot, LoadedDurableCheckpoint,
57    },
58    errors::{BlockContextError, StorageFetchResult},
59    events::{EventDecoder, StateView},
60    freshness::FreshnessRegistry,
61    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
62};
63
64/// Input accepted by the reactive runtime.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum ReactiveInput<N: Network = Ethereum> {
67    /// A canonical or removed EVM log, using Alloy's RPC log type.
68    Log(Log),
69    /// A block header response for header-oriented handlers.
70    BlockHeader(N::HeaderResponse),
71    /// A full block response for block handlers that need transaction bodies.
72    FullBlock(N::BlockResponse),
73    /// A pending transaction hash.
74    PendingTxHash(B256),
75    /// A full pending transaction body.
76    PendingTx(N::TransactionResponse),
77}
78
79/// Context supplied with each [`ReactiveInput`].
80#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81pub struct ReactiveContext {
82    /// Chain id, when known.
83    pub chain_id: Option<u64>,
84    /// Where the input came from.
85    pub source: InputSource,
86    /// Lifecycle status of the input.
87    pub chain_status: ChainStatus,
88    /// Block metadata associated with the input, when known.
89    pub block: Option<BlockRef>,
90    /// Transaction index for log or transaction inputs.
91    pub transaction_index: Option<u64>,
92    /// Log index for log inputs.
93    pub log_index: Option<u64>,
94}
95
96/// Minimal block identity carried through reports.
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
98pub struct BlockRef {
99    /// Block number.
100    pub number: u64,
101    /// Block hash.
102    pub hash: B256,
103    /// Parent hash, when known.
104    pub parent_hash: Option<B256>,
105    /// Block timestamp, when known.
106    pub timestamp: Option<u64>,
107}
108
109/// Stable provider identity attached to provider-originated input.
110///
111/// `generation` changes whenever a caller replaces or reconnects the concrete
112/// provider session behind the same configured endpoint. Follow-up reads can
113/// use this value to prefer the exact source that announced speculative state
114/// without putting URLs or credentials into event payloads.
115#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
116pub struct ProviderRef {
117    /// Operator-defined endpoint identity.
118    pub endpoint: EndpointId,
119    /// Concrete connection/session generation.
120    pub generation: u64,
121}
122
123impl ProviderRef {
124    /// Construct provider provenance for one connection generation.
125    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
126        Self {
127            endpoint: endpoint.into(),
128            generation,
129        }
130    }
131}
132
133/// Identity of one cumulative pre-confirmed Flashblock snapshot.
134#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
135pub struct FlashblockRef {
136    /// Provider session that supplied this snapshot.
137    pub provider: ProviderRef,
138    /// Sequencer payload id shared by every Flashblock in the full block.
139    ///
140    /// OP RPCs that expose only the standard pending block surface may not
141    /// expose this Base-native identifier.
142    pub payload_id: Option<FixedBytes<8>>,
143    /// Zero-based Flashblock index, when exposed by the endpoint.
144    pub index: Option<u64>,
145    /// Pending block number represented by this cumulative snapshot.
146    pub block_number: u64,
147    /// Hash of the cumulative partial block at this snapshot.
148    pub block_hash: B256,
149    /// Canonical parent of the pending block, when exposed.
150    pub parent_hash: Option<B256>,
151    /// State root after this cumulative snapshot, when exposed.
152    pub state_root: Option<B256>,
153    /// Pending block timestamp, when exposed.
154    pub timestamp: Option<u64>,
155}
156
157impl FlashblockRef {
158    /// Convert the pre-confirmed identity into the block metadata used by
159    /// ordinary log routing. The hash is explicitly a partial/pending hash and
160    /// must not advance canonical coverage.
161    pub const fn block_ref(&self) -> BlockRef {
162        BlockRef {
163            number: self.block_number,
164            hash: self.block_hash,
165            parent_hash: self.parent_hash,
166            timestamp: self.timestamp,
167        }
168    }
169
170    fn same_payload(&self, other: &Self) -> bool {
171        self.provider == other.provider
172            && match (self.payload_id, other.payload_id) {
173                (Some(left), Some(right)) => left == right,
174                _ => {
175                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
176                }
177            }
178    }
179}
180
181/// Whether the subscriber may use Flashblocks for speculative delivery.
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
183pub enum PreconfirmationMode {
184    /// Use only canonical subscription/polling behavior.
185    #[default]
186    Disabled,
187    /// Prefer Flashblocks, but retain canonical operation when the selected
188    /// chain/provider cannot establish the pre-confirmation stream.
189    Preferred,
190    /// Fail setup/reconnect closed unless Flashblocks can be established.
191    Required,
192}
193
194/// Base-native `newFlashblocks` subscription payload.
195#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
196pub struct BaseFlashblockPayload {
197    /// Block-builder payload id shared by every incremental snapshot.
198    pub payload_id: FixedBytes<8>,
199    /// Zero-based incremental snapshot index.
200    pub index: u64,
201    /// Header fields present on index zero.
202    pub base: Option<BaseFlashblockBase>,
203    /// Cumulative state commitments for this snapshot.
204    pub diff: BaseFlashblockDiff,
205    /// Supplemental block identity retained across current Base versions.
206    #[serde(default)]
207    pub metadata: Option<BaseFlashblockMetadata>,
208}
209
210/// Stable index-zero header subset from Base's Flashblocks wire format.
211#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
212pub struct BaseFlashblockBase {
213    /// Canonical parent block hash.
214    pub parent_hash: B256,
215    /// Pending block number.
216    #[serde(deserialize_with = "deserialize_rpc_u64")]
217    pub block_number: u64,
218    /// Pending block timestamp.
219    #[serde(deserialize_with = "deserialize_rpc_u64")]
220    pub timestamp: u64,
221}
222
223/// Stable commitment subset from Base's Flashblocks wire format.
224#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
225pub struct BaseFlashblockDiff {
226    /// State root after this cumulative snapshot.
227    pub state_root: B256,
228    /// Partial block hash after this cumulative snapshot.
229    pub block_hash: B256,
230}
231
232/// Stable metadata subset used when index-greater-than-zero payloads omit the
233/// Base header object.
234#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
235pub struct BaseFlashblockMetadata {
236    /// Pending block number (currently encoded as a JSON integer).
237    #[serde(deserialize_with = "deserialize_rpc_u64")]
238    pub block_number: u64,
239}
240
241/// Current Base/QuickNode `newFlashblocks` wire shape. The endpoint emits a
242/// cumulative block-shaped snapshot for every partial block update.
243#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
244#[serde(rename_all = "camelCase")]
245struct BaseFlashblockBlockPayload {
246    hash: B256,
247    #[serde(deserialize_with = "deserialize_rpc_u64")]
248    number: u64,
249    parent_hash: B256,
250    state_root: B256,
251    #[serde(deserialize_with = "deserialize_rpc_u64")]
252    timestamp: u64,
253}
254
255/// Base has exposed both an indexed diff envelope and a cumulative
256/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
257/// differences do not force callers onto separate subscriber paths.
258#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
259#[serde(untagged)]
260enum BaseFlashblockWirePayload {
261    Indexed(BaseFlashblockPayload),
262    Block(BaseFlashblockBlockPayload),
263}
264
265fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
266where
267    D: serde::Deserializer<'de>,
268{
269    #[derive(serde::Deserialize)]
270    #[serde(untagged)]
271    enum RpcU64 {
272        Number(u64),
273        String(String),
274    }
275
276    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
277        RpcU64::Number(number) => Ok(number),
278        RpcU64::String(value) => {
279            let value = value.strip_prefix("0x").unwrap_or(&value);
280            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
281        }
282    }
283}
284
285/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
286/// point for reactive event continuity.
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
288pub struct ReactiveCanonicalBaseline {
289    /// Chain whose state the cache snapshot contains.
290    pub chain_id: u64,
291    /// Canonical block through which the snapshot already embodies state.
292    pub block: BlockRef,
293}
294
295impl ReactiveCanonicalBaseline {
296    /// Construct an exact cache snapshot baseline.
297    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
298        Self { chain_id, block }
299    }
300}
301
302/// Ordered chain-lifecycle control delivered by an event subscriber.
303///
304/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
305/// token, durable checkpoint, and ordering guarantees as ordinary event data.
306/// Reorg controls are applied in declaration order before replacement records;
307/// progress, barrier, safe, and finalized controls are committed in declaration
308/// order after the records. A reorg declared after a post-record control is
309/// rejected because its ordering would otherwise be ambiguous.
310#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
311#[non_exhaustive]
312pub enum ChainControl {
313    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
314    Reorg {
315        /// Last block common to the old and new canonical branches.
316        common_ancestor: BlockRef,
317        /// Tip of the branch that ceased to be canonical.
318        old_tip: BlockRef,
319        /// Tip of the newly canonical branch known by the source.
320        new_tip: BlockRef,
321    },
322    /// Update the source's safe head.
323    Safe(BlockRef),
324    /// Update the source's finalized head.
325    Finalized(BlockRef),
326    /// Advance authoritative canonical coverage without fabricating a full header.
327    ///
328    /// Indexers that only know compact block identity should emit this control.
329    /// It never runs block handlers. The runtime exact-hash pins provider reads
330    /// and installs known `NUMBER`/timestamp values, but clears unproven
331    /// header-only environment fields such as base fee and beneficiary.
332    CanonicalProgress(BlockRef),
333    /// Ordered cutover or synchronization fence.
334    Barrier {
335        /// Subscriber-defined opaque barrier identity.
336        id: Vec<u8>,
337        /// Highest canonical event block included before the fence, if known.
338        block: Option<BlockRef>,
339    },
340}
341
342/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
343///
344/// Composite subscribers can persist this small chain-state view beside their
345/// own delivery checkpoint and validate a complete delivery envelope before it
346/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
347/// without matching events need not be present), but it must contain at most
348/// one compatible identity per height. Its oldest entry is also the durable
349/// rollback horizon: an unretained explicit ancestor is accepted only when that
350/// oldest entry is at or below the ancestor. This type carries no cache data,
351/// event payloads, handler state, or transport-specific cursor.
352///
353/// The serde representation is a convenience for caller-owned persistence; it
354/// is not a versioned wire or checkpoint format. Durable protocols should wrap
355/// it in their own versioned envelope and define migrations before upgrading
356/// this pre-1.0 crate. External callers also own retention: successful
357/// validation appends canonical identities but does not silently discard the
358/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
359/// committing the matching source cursor/ACK.
360#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
361pub struct CanonicalSequenceState {
362    retained_canonical_history: Vec<BlockRef>,
363    coverage_head: Option<BlockRef>,
364    safe_head: Option<BlockRef>,
365    finalized_head: Option<BlockRef>,
366}
367
368impl CanonicalSequenceState {
369    /// Construct a validation snapshot from retained canonical metadata.
370    ///
371    /// Construction does not validate ordering, adjacency, coverage, or
372    /// finality invariants. Call [`Self::validate`] before installing decoded or
373    /// externally assembled state.
374    pub fn new(
375        retained_canonical_history: Vec<BlockRef>,
376        coverage_head: Option<BlockRef>,
377        safe_head: Option<BlockRef>,
378        finalized_head: Option<BlockRef>,
379    ) -> Self {
380        Self {
381            retained_canonical_history,
382            coverage_head,
383            safe_head,
384            finalized_head,
385        }
386    }
387
388    /// Sparse retained canonical history in ascending processing order.
389    pub fn retained_canonical_history(&self) -> &[BlockRef] {
390        &self.retained_canonical_history
391    }
392
393    /// Highest canonical identity covered by this state, when known.
394    pub const fn coverage_head(&self) -> Option<&BlockRef> {
395        self.coverage_head.as_ref()
396    }
397
398    /// Latest safe head accepted by the validator, when known.
399    pub const fn safe_head(&self) -> Option<&BlockRef> {
400        self.safe_head.as_ref()
401    }
402
403    /// Latest finalized head accepted by the validator, when known.
404    pub const fn finalized_head(&self) -> Option<&BlockRef> {
405        self.finalized_head.as_ref()
406    }
407
408    /// Retain at most the newest `max_entries` canonical history identities.
409    ///
410    /// Coverage and safe/finalized heads are unchanged. The oldest retained
411    /// identity defines how far strict validation can prove a complete
412    /// rollback, so choose a bound at least as large as the deployment's
413    /// supported reorg depth and trim only after atomically committing the
414    /// corresponding validated state and source cursor. `0` intentionally
415    /// produces a coverage-only snapshot.
416    pub fn retain_recent_history(&mut self, max_entries: usize) {
417        let remove = self
418            .retained_canonical_history
419            .len()
420            .saturating_sub(max_entries);
421        self.retained_canonical_history.drain(..remove);
422    }
423
424    /// Validate a decoded/checkpointed snapshot before installing it.
425    ///
426    /// This rejects out-of-order or conflicting retained identities,
427    /// broken adjacent parent links, retained history without coverage,
428    /// incompatible coverage/finality aliases, hash reuse across heights,
429    /// known parent hashes at non-adjacent heights, finality beyond coverage,
430    /// and a finalized head beyond or conflicting with the safe head.
431    ///
432    /// # Errors
433    ///
434    /// Returns [`ReactiveError`] when any retained identity, parent link,
435    /// coverage alias, or safe/finalized relationship violates the canonical
436    /// snapshot invariants described above.
437    pub fn validate(&self) -> Result<(), ReactiveError> {
438        validate_canonical_sequence_snapshot(self)
439    }
440}
441
442/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
443#[derive(Clone, Debug, PartialEq, Eq)]
444#[non_exhaustive]
445pub enum CanonicalSequenceMutation {
446    /// Rewind the listed retained identities and continue from `common_ancestor`.
447    Rewind {
448        /// Surviving canonical anchor, when one is retained or authenticated.
449        /// `None` is a transient same-envelope state: callers must stage the
450        /// complete validation atomically and may checkpoint only the returned
451        /// `next_state`, after a later canonical mutation installs the proven
452        /// replacement.
453        common_ancestor: Option<BlockRef>,
454        /// Exact retained identities removed by the transition.
455        dropped: Vec<BlockRef>,
456    },
457    /// Accept or enrich one canonical identity.
458    Canonical(BlockRef),
459    /// Accept a safe-head update with metadata resolved against prior state.
460    Safe(BlockRef),
461    /// Accept a finalized-head update with metadata resolved against prior state.
462    Finalized(BlockRef),
463}
464
465/// Successful result of provider-neutral canonical envelope validation.
466#[derive(Clone, Debug, PartialEq, Eq)]
467pub struct CanonicalSequenceValidation {
468    pre_record_state: CanonicalSequenceState,
469    next_state: CanonicalSequenceState,
470    mutations: Vec<CanonicalSequenceMutation>,
471    normalized_chain_controls: Vec<ChainControl>,
472}
473
474impl CanonicalSequenceValidation {
475    /// State after pre-record explicit reorg controls and before event records.
476    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
477        &self.pre_record_state
478    }
479
480    /// Fully validated state after records and post-record controls.
481    pub const fn next_state(&self) -> &CanonicalSequenceState {
482        &self.next_state
483    }
484
485    /// Ordered cache-free canonical mutations proven by this envelope.
486    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
487        &self.mutations
488    }
489
490    /// Controls safe to forward after composite overlap normalization.
491    ///
492    /// Ordinary validation retains the original controls. See
493    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
494    /// compatible stale progress and converts a stale blockful barrier into the
495    /// same barrier identity without a block assertion. Equal-height controls
496    /// that add previously absent parent/timestamp metadata remain present;
497    /// older compatible enrichment is intentionally not applied because the
498    /// corresponding regressive control is not forwarded to the runtime.
499    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
500        &self.normalized_chain_controls
501    }
502}
503
504/// Lifecycle status for an input.
505#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
506#[non_exhaustive]
507pub enum ChainStatus {
508    /// The input is mempool-only and must not mutate canonical cache state.
509    Pending,
510    /// The input is ordered into an ephemeral sequencer-built Flashblock.
511    ///
512    /// Handlers may update the runtime's speculative overlay for this status,
513    /// but the update never advances canonical coverage or durable journals.
514    Preconfirmed {
515        /// Exact cumulative pre-confirmation snapshot observed by the source.
516        flashblock: FlashblockRef,
517    },
518    /// The input is included in a block with a confirmation count.
519    Included {
520        /// Included block.
521        block: BlockRef,
522        /// Confirmation count.
523        confirmations: u64,
524    },
525    /// The input is in the chain's safe head.
526    Safe {
527        /// Safe block.
528        block: BlockRef,
529    },
530    /// The input is in the finalized head.
531    Finalized {
532        /// Finalized block.
533        block: BlockRef,
534    },
535    /// The input was dropped by a reorg.
536    Reorged {
537        /// Block the input was dropped from.
538        dropped_from: BlockRef,
539    },
540}
541
542/// Source of an input batch.
543#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
544#[non_exhaustive]
545pub enum InputSource {
546    /// Caller-supplied batch.
547    Batch,
548    /// Live subscription stream.
549    Subscription,
550    /// Polling subscriber.
551    Poll,
552    /// Historical backfill.
553    Backfill,
554    /// Sequencer pre-confirmation / Flashblocks surface.
555    Flashblocks,
556    /// Test or synthetic input.
557    Synthetic,
558}
559
560/// Stable identity used for input deduplication and reports.
561#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
562pub enum InputRef {
563    /// Stable log identity.
564    Log {
565        /// Chain id, when known.
566        chain_id: Option<u64>,
567        /// Block hash containing the log.
568        block_hash: B256,
569        /// Transaction hash that emitted the log.
570        transaction_hash: B256,
571        /// Log index within the block.
572        log_index: u64,
573    },
574    /// Stable pending transaction identity.
575    PendingTx {
576        /// Chain id, when known.
577        chain_id: Option<u64>,
578        /// Transaction hash.
579        hash: B256,
580    },
581    /// Stable block identity.
582    Block {
583        /// Chain id, when known.
584        chain_id: Option<u64>,
585        /// Block hash.
586        hash: B256,
587        /// Block number.
588        number: u64,
589    },
590}
591
592/// Representation and lifecycle class retained alongside an [`InputRef`].
593///
594/// `InputRef` identifies the underlying chain object. This discriminator keeps
595/// distinct handler inputs from collapsing merely because they commit to the
596/// same object: a header and full block, a pending hash and hydrated body, and
597/// canonical versus reorg-signalling log delivery are independently routable.
598#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
599#[non_exhaustive]
600pub enum ReactiveInputKind {
601    /// Canonical log data.
602    CanonicalLog,
603    /// Removed or otherwise reorg-signalling log data.
604    ReorgSignalLog,
605    /// Header-only block representation.
606    BlockHeader,
607    /// Full block representation.
608    FullBlock,
609    /// Hash-only pending transaction representation.
610    PendingTxHash,
611    /// Hydrated pending transaction representation.
612    PendingTx,
613}
614
615/// Validated, representation-aware identity for one reactive input.
616///
617/// Composite subscribers can use this as a dedupe key without conflating
618/// independently routable representations. When a key repeats, use
619/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
620/// provider overlap from a conflicting payload.
621#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
622pub struct ReactiveInputIdentity {
623    input_ref: InputRef,
624    kind: ReactiveInputKind,
625}
626
627impl ReactiveInputIdentity {
628    /// Validate and construct an identity from explicit wire/codec parts.
629    ///
630    /// `InputRef` identifies the underlying object, while `kind` identifies its
631    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
632    /// block representations with [`InputRef::Block`], and pending-transaction
633    /// representations with [`InputRef::PendingTx`]. This constructor lets
634    /// external codecs rebuild the otherwise-private invariant without serde or
635    /// layout-dependent decoding.
636    ///
637    /// # Errors
638    ///
639    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
640    /// to the supplied representation `kind`.
641    pub fn try_from_parts(
642        input_ref: InputRef,
643        kind: ReactiveInputKind,
644    ) -> Result<Self, ReactiveInputIdentityError> {
645        let compatible = matches!(
646            (input_ref, kind),
647            (
648                InputRef::Log { .. },
649                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
650            ) | (
651                InputRef::Block { .. },
652                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
653            ) | (
654                InputRef::PendingTx { .. },
655                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
656            )
657        );
658        if !compatible {
659            return Err(ReactiveInputIdentityError { input_ref, kind });
660        }
661        Ok(Self { input_ref, kind })
662    }
663
664    /// Underlying stable chain-object reference.
665    pub const fn input_ref(&self) -> InputRef {
666        self.input_ref
667    }
668
669    /// Exact handler-input representation and lifecycle class.
670    pub const fn kind(&self) -> ReactiveInputKind {
671        self.kind
672    }
673}
674
675/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
676/// object/representation classes.
677#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
678#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
679pub struct ReactiveInputIdentityError {
680    input_ref: InputRef,
681    kind: ReactiveInputKind,
682}
683
684impl ReactiveInputIdentityError {
685    /// Rejected stable object reference.
686    pub const fn input_ref(&self) -> InputRef {
687        self.input_ref
688    }
689
690    /// Rejected representation/lifecycle kind.
691    pub const fn kind(&self) -> ReactiveInputKind {
692        self.kind
693    }
694}
695
696/// Reliability of state effects emitted by a handler.
697#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
698pub enum StateEffectQuality {
699    /// Effects are exact from the input alone.
700    ExactFromInput,
701    /// Effects were applied, but follow-up resync is pending.
702    AppliedWithPendingResync,
703    /// Effects came from authoritative resync.
704    ResyncedAuthoritatively,
705    /// State requires repair before it should be trusted.
706    RequiresRepair,
707    /// No canonical state effect was emitted.
708    NoStateEffect,
709}
710
711/// Identifier for a reactive handler.
712#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
713pub struct HandlerId(String);
714
715impl HandlerId {
716    /// Create a non-empty handler id.
717    ///
718    /// # Panics
719    ///
720    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
721    /// configuration or wire input.
722    pub fn new(id: impl Into<String>) -> Self {
723        Self::try_new(id).expect("handler id must not be empty")
724    }
725
726    /// Validate and create a handler id from untrusted input.
727    ///
728    /// # Errors
729    ///
730    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
731    /// reserved for canonical/global protocol scope.
732    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
733        let id = id.into();
734        if id.is_empty() {
735            return Err(HandlerIdError);
736        }
737        Ok(Self(id))
738    }
739
740    /// Return the id as a string slice.
741    pub fn as_str(&self) -> &str {
742        &self.0
743    }
744}
745
746impl<'de> serde::Deserialize<'de> for HandlerId {
747    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
748    where
749        D: serde::Deserializer<'de>,
750    {
751        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
752        Self::try_new(id).map_err(serde::de::Error::custom)
753    }
754}
755
756/// An empty handler identity cannot be represented portably across subscriber
757/// protocols because the empty owner is reserved for canonical/global scope.
758#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
759#[error("handler id must not be empty")]
760pub struct HandlerIdError;
761
762impl fmt::Display for HandlerId {
763    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
764        self.0.fmt(f)
765    }
766}
767
768/// Lightweight report label.
769#[derive(Clone, Debug, PartialEq, Eq, Hash)]
770pub struct ReportTag {
771    /// Label key.
772    pub key: String,
773    /// Label value.
774    pub value: String,
775}
776
777impl ReportTag {
778    /// Create a report tag.
779    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
780        Self {
781            key: key.into(),
782            value: value.into(),
783        }
784    }
785}
786
787/// Domain-neutral hook signal emitted by a handler.
788#[derive(Clone)]
789pub struct HookSignal {
790    /// Signal namespace owned by the caller.
791    pub namespace: Cow<'static, str>,
792    /// Signal kind within the namespace.
793    pub kind: Cow<'static, str>,
794    /// Additional labels for routing or observability.
795    pub labels: Vec<ReportTag>,
796    /// Optional in-process typed payload.
797    pub payload: Option<Arc<dyn Any + Send + Sync>>,
798}
799
800impl fmt::Debug for HookSignal {
801    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
802        f.debug_struct("HookSignal")
803            .field("namespace", &self.namespace)
804            .field("kind", &self.kind)
805            .field("labels", &self.labels)
806            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
807            .finish()
808    }
809}
810
811/// Effect emitted by a [`ReactiveHandler`].
812#[derive(Clone, Debug)]
813pub enum ReactiveEffect {
814    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
815    StateUpdate(StateUpdate),
816    /// Request for authoritative state repair.
817    Resync(ResyncRequest),
818    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
819    Invalidate(InvalidationRequest),
820    /// Hook signal dispatched after committed mutation phases.
821    Hook(HookSignal),
822    /// Speculative signal for mempool or downstream work.
823    Speculative(SpeculativeRequest),
824}
825
826/// Handler output for a single input.
827#[derive(Clone, Debug)]
828pub struct HandlerOutcome {
829    /// Effects emitted by the handler.
830    pub effects: Vec<ReactiveEffect>,
831    /// Reliability of emitted state effects.
832    pub quality: StateEffectQuality,
833    /// Labels copied into reports.
834    pub tags: Vec<ReportTag>,
835}
836
837impl HandlerOutcome {
838    /// Construct an empty outcome with the supplied quality.
839    pub fn empty(quality: StateEffectQuality) -> Self {
840        Self {
841            effects: Vec::new(),
842            quality,
843            tags: Vec::new(),
844        }
845    }
846}
847
848/// One input and its execution context.
849#[derive(Clone, Debug)]
850pub struct ReactiveInputRecord<N: Network = Ethereum> {
851    /// Input value.
852    pub input: ReactiveInput<N>,
853    /// Input context.
854    pub context: ReactiveContext,
855    /// Provider session that originated this input, when it came from a
856    /// concrete provider rather than a synthetic or aggregate source.
857    pub provider: Option<ProviderRef>,
858}
859
860impl<N: Network> ReactiveInputRecord<N> {
861    /// Create an input record.
862    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
863        Self {
864            input,
865            context,
866            provider: None,
867        }
868    }
869
870    /// Attach provider provenance used to route follow-up reads.
871    #[must_use]
872    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
873        self.provider = Some(provider);
874        self
875    }
876
877    /// Compute the stable input reference used for deduplication.
878    pub fn input_ref(&self) -> InputRef {
879        input_ref(&self.input, &self.context)
880    }
881
882    /// Validate payload/context coherence and return a representation-aware
883    /// identity suitable for subscriber and runtime deduplication.
884    ///
885    /// Validation is fail-closed for canonical logs: their block, transaction,
886    /// and log positions must be complete and agree with the context. Block and
887    /// pending-transaction representations receive the corresponding lifecycle,
888    /// inclusion-wrapper, and payload/context checks. This does not recompute a
889    /// claimed header hash, transaction root, or transaction signature; exact
890    /// subscriber payload commitments remain the transport-integrity boundary
891    /// for those cryptographic claims.
892    ///
893    /// # Errors
894    ///
895    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
896    /// lifecycle, inclusion metadata, or context is incomplete or internally
897    /// inconsistent.
898    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
899        validate_input_record(self)?;
900        let kind = match &self.input {
901            ReactiveInput::Log(log)
902                if log.removed
903                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
904            {
905                ReactiveInputKind::ReorgSignalLog
906            }
907            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
908            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
909            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
910            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
911            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
912        };
913        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
914            ReactiveError::InvalidInputRecord {
915                message: error.to_string(),
916            }
917        })
918    }
919
920    /// Whether two same-identity records carry the same deduplicable payload.
921    ///
922    /// This deliberately ignores [`ReactiveContext`]: the same provider object
923    /// can legitimately arrive from backfill and subscription transports with
924    /// different provenance or confirmation metadata. Callers must first
925    /// compare [`validated_identity`](Self::validated_identity) and reconcile
926    /// lifecycle/context authority separately. Logs are compared structurally;
927    /// block and transaction hashes are cryptographic commitments for the
928    /// remaining same-representation payloads. Full block responses and
929    /// hydrated pending transaction bodies deliberately return `false`: the
930    /// core does not currently prove a supplied body against the header's
931    /// transaction root or compare every response field, so a composite source
932    /// must preserve both rather than suppress one based only on its hash.
933    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
934        match (&self.input, &other.input) {
935            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
936                left.inner == right.inner
937                    && left.block_hash == right.block_hash
938                    && left.block_number == right.block_number
939                    && optional_metadata_compatible(
940                        left.block_timestamp.as_ref(),
941                        right.block_timestamp.as_ref(),
942                    )
943                    && left.transaction_hash == right.transaction_hash
944                    && left.transaction_index == right.transaction_index
945                    && left.log_index == right.log_index
946                    && left.removed == right.removed
947            }
948            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
949                left.hash() == right.hash()
950            }
951            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
952            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
953                left == right
954            }
955            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
956            _ => false,
957        }
958    }
959
960    /// Whether this representation has a complete payload-equivalence contract
961    /// and may participate in duplicate suppression.
962    ///
963    /// Full block and hydrated pending transaction bodies are intentionally
964    /// excluded until their complete body/response integrity is validated.
965    pub fn is_payload_deduplicable(&self) -> bool {
966        matches!(
967            &self.input,
968            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
969        )
970    }
971
972    /// Merge `other` when it is the same safely deduplicable provider object.
973    ///
974    /// Returns `Ok(false)` for a different identity or a representation whose
975    /// complete payload cannot be proven equivalent. A same-identity payload or
976    /// semantic conflict returns an error. Successful merges are deterministic:
977    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
978    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
979    /// and provenance uses a stable source priority. The result is therefore
980    /// independent of historical/live arrival order.
981    ///
982    /// # Errors
983    ///
984    /// Returns [`ReactiveError`] when either record is invalid, or when equal
985    /// identities carry conflicting payload or semantic context.
986    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
987        let identity = self.validated_identity()?;
988        let other_identity = other.validated_identity()?;
989        if identity != other_identity
990            || !self.is_payload_deduplicable()
991            || !other.is_payload_deduplicable()
992        {
993            return Ok(false);
994        }
995        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
996            return Err(ReactiveError::InvalidInputRecord {
997                message: format!(
998                    "conflicting payload or semantic context for identity {:?}",
999                    identity
1000                ),
1001            });
1002        }
1003        let mut merged = self.clone();
1004        merge_deduplicable_record(&mut merged, other);
1005        merged.validated_identity()?;
1006        *self = merged;
1007        Ok(true)
1008    }
1009
1010    /// Whether semantic context agrees for deduplication across transports.
1011    ///
1012    /// Provenance source and confirmation count may legitimately differ at a
1013    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1014    /// transaction/log positions must agree. Block number/hash are exact;
1015    /// optional parent/timestamp metadata may be enriched by one source but two
1016    /// present conflicting values are rejected.
1017    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1018        let left = &self.context;
1019        let right = &other.context;
1020        left.chain_id == right.chain_id
1021            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1022            && left.transaction_index == right.transaction_index
1023            && left.log_index == right.log_index
1024            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1025    }
1026}
1027
1028fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1029    match (left, right) {
1030        (ChainStatus::Pending, ChainStatus::Pending)
1031        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1032        (
1033            ChainStatus::Preconfirmed { flashblock: left },
1034            ChainStatus::Preconfirmed { flashblock: right },
1035        ) => left == right,
1036        (
1037            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1038            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1039        ) => true,
1040        _ => false,
1041    }
1042}
1043
1044fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1045    left.zip(right).is_none_or(|(left, right)| left == right)
1046}
1047
1048fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1049    match (left, right) {
1050        (None, None) => true,
1051        (Some(left), Some(right)) => {
1052            left.number == right.number
1053                && left.hash == right.hash
1054                && optional_metadata_compatible(
1055                    left.parent_hash.as_ref(),
1056                    right.parent_hash.as_ref(),
1057                )
1058                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1059        }
1060        _ => false,
1061    }
1062}
1063
1064fn merge_deduplicable_record<N: Network>(
1065    retained: &mut ReactiveInputRecord<N>,
1066    incoming: &ReactiveInputRecord<N>,
1067) {
1068    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1069        (&mut retained.input, &incoming.input)
1070        && retained.block_timestamp.is_none()
1071    {
1072        retained.block_timestamp = incoming.block_timestamp;
1073    }
1074    if let (Some(retained), Some(incoming)) =
1075        (&mut retained.context.block, incoming.context.block.as_ref())
1076    {
1077        enrich_block_ref(retained, incoming);
1078    }
1079    retained.context.chain_status = merged_chain_status(
1080        &retained.context.chain_status,
1081        &incoming.context.chain_status,
1082    );
1083    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1084        retained.context.source = incoming.context.source;
1085    }
1086    if retained.provider.is_none() {
1087        retained.provider = incoming.provider.clone();
1088    }
1089}
1090
1091fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1092    if retained.parent_hash.is_none() {
1093        retained.parent_hash = incoming.parent_hash;
1094    }
1095    if retained.timestamp.is_none() {
1096        retained.timestamp = incoming.timestamp;
1097    }
1098}
1099
1100fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1101    let merged_block = |left: &BlockRef, right: &BlockRef| {
1102        let mut block = *left;
1103        enrich_block_ref(&mut block, right);
1104        block
1105    };
1106    match (retained, incoming) {
1107        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1108        (
1109            ChainStatus::Preconfirmed { flashblock: left },
1110            ChainStatus::Preconfirmed { flashblock: right },
1111        ) => {
1112            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1113            ChainStatus::Preconfirmed {
1114                flashblock: left.clone(),
1115            }
1116        }
1117        (
1118            ChainStatus::Reorged { dropped_from: left },
1119            ChainStatus::Reorged {
1120                dropped_from: right,
1121            },
1122        ) => ChainStatus::Reorged {
1123            dropped_from: merged_block(left, right),
1124        },
1125        (left, right) => {
1126            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1127                .expect("compatible duplicate has a canonical lifecycle");
1128            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1129                .expect("compatible duplicate has a canonical lifecycle");
1130            let block = merged_block(left_block, right_block);
1131            let rank = left_rank.max(right_rank);
1132            match rank {
1133                3 => ChainStatus::Finalized { block },
1134                2 => ChainStatus::Safe { block },
1135                _ => ChainStatus::Included {
1136                    block,
1137                    confirmations: left_confirmations.max(right_confirmations),
1138                },
1139            }
1140        }
1141    }
1142}
1143
1144fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1145    match status {
1146        ChainStatus::Included {
1147            block,
1148            confirmations,
1149        } => Some((block, 1, *confirmations)),
1150        ChainStatus::Safe { block } => Some((block, 2, 0)),
1151        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1152        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1153            None
1154        }
1155    }
1156}
1157
1158fn input_source_rank(source: InputSource) -> u8 {
1159    match source {
1160        InputSource::Backfill => 0,
1161        InputSource::Poll => 1,
1162        InputSource::Subscription => 2,
1163        InputSource::Flashblocks => 3,
1164        InputSource::Batch => 4,
1165        InputSource::Synthetic => 5,
1166    }
1167}
1168
1169/// Opaque subscriber-owned token attached to a delivered input batch.
1170///
1171/// Subscribers that provide durable, at-least-once delivery can use this token
1172/// to identify the batch that becomes committable after runtime ingestion
1173/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1174/// stable across replay, and must never identify two different batch payloads.
1175/// Subscriber implementations must preserve delivery order while one token is
1176/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1177/// later batch.
1178#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1179pub struct SubscriberDeliveryToken(Vec<u8>);
1180
1181impl SubscriberDeliveryToken {
1182    /// Create an opaque delivery token from subscriber-owned bytes.
1183    pub fn new(bytes: Vec<u8>) -> Self {
1184        Self(bytes)
1185    }
1186
1187    /// Borrow the opaque token bytes.
1188    pub fn as_bytes(&self) -> &[u8] {
1189        &self.0
1190    }
1191
1192    /// Consume the token into its opaque bytes.
1193    pub fn into_bytes(self) -> Vec<u8> {
1194        self.0
1195    }
1196}
1197
1198/// Opaque source checkpoint associated with a delivered batch.
1199///
1200/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1201/// acknowledge, this value describes provider-specific resume state. The core
1202/// crate persists and returns the bytes without interpreting their format.
1203#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1204pub struct SubscriberCheckpoint(Vec<u8>);
1205
1206impl SubscriberCheckpoint {
1207    /// Create an opaque source checkpoint from subscriber-owned bytes.
1208    pub fn new(bytes: Vec<u8>) -> Self {
1209        Self(bytes)
1210    }
1211
1212    /// Borrow the opaque checkpoint bytes.
1213    pub fn as_bytes(&self) -> &[u8] {
1214        &self.0
1215    }
1216
1217    /// Consume the checkpoint into its opaque bytes.
1218    pub fn into_bytes(self) -> Vec<u8> {
1219        self.0
1220    }
1221}
1222
1223/// Subscriber-supplied commitment to the exact canonical wire payload of one
1224/// delivered batch.
1225///
1226/// The core includes this value in its durable replay witness. It is required
1227/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1228/// network-generic Rust response types cannot be serialized completely by the
1229/// core. The source must recompute the commitment from a stable canonical
1230/// encoding on every replay; reusing a commitment for changed bytes violates the
1231/// [`EventSubscriber`] contract.
1232#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1233pub struct SubscriberPayloadCommitment(B256);
1234
1235impl SubscriberPayloadCommitment {
1236    /// Wrap a cryptographic commitment produced by the subscriber.
1237    pub const fn new(commitment: B256) -> Self {
1238        Self(commitment)
1239    }
1240
1241    /// Return the committed digest.
1242    pub const fn digest(&self) -> B256 {
1243        self.0
1244    }
1245}
1246
1247/// Durable subscriber position restored together with cache/runtime state.
1248///
1249/// The core never interprets provider checkpoint bytes. Composite and remote
1250/// subscribers use this synchronous hand-off to seed their source cursors,
1251/// replay fences, and canonical overlap journals before polling resumes.
1252#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1253#[non_exhaustive]
1254pub struct SubscriberResumePosition {
1255    /// Chain whose canonical position and provider cursor are being restored.
1256    pub chain_id: u64,
1257    /// Authoritative canonical coverage embodied by the restored cache.
1258    pub coverage_head: BlockRef,
1259    /// Ordered canonical identities still retained for in-window reconciliation.
1260    pub canonical_history: Vec<BlockRef>,
1261    /// Last delivery token whose effects are already represented by the cache.
1262    /// It may still be pending at the source when the process stopped after its
1263    /// durable save but before the source acknowledgement committed.
1264    pub delivery_token: Option<SubscriberDeliveryToken>,
1265    /// Provider-specific durable cursor committed with that delivery.
1266    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1267}
1268
1269impl SubscriberResumePosition {
1270    /// Construct a complete restored subscriber position.
1271    pub fn new(
1272        chain_id: u64,
1273        coverage_head: BlockRef,
1274        canonical_history: Vec<BlockRef>,
1275        delivery_token: Option<SubscriberDeliveryToken>,
1276        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1277    ) -> Self {
1278        Self {
1279            chain_id,
1280            coverage_head,
1281            canonical_history,
1282            delivery_token,
1283            subscriber_checkpoint,
1284        }
1285    }
1286}
1287
1288/// Runtime routing audience for one delivered subscriber batch.
1289///
1290/// Historical catch-up for a newly registered handler must not be routed
1291/// through older handlers whose filters happen to overlap. Subscribers retain
1292/// that provenance by targeting the batch at the exact logical owners that
1293/// requested it. Ordinary canonical delivery remains broadcast to every
1294/// matching handler.
1295#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1296#[non_exhaustive]
1297pub enum DeliveryAudience {
1298    /// Route each record through every matching registered handler.
1299    #[default]
1300    All,
1301    /// Route each record only through the named matching handlers.
1302    Owners(Vec<HandlerId>),
1303    /// Route through every matching handler except the named owners.
1304    ///
1305    /// Composite subscribers use this to deliver the residual audience after an
1306    /// overlapping source already committed the same input for selected owners.
1307    AllExcept(Vec<HandlerId>),
1308}
1309
1310/// How one delivered record participates in the runtime's canonical state machine.
1311///
1312/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1313/// selects handlers, while this value decides whether a record may advance or
1314/// rewind global chain state. Historical replay for a newly added owner must use
1315/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1316/// is canonical.
1317#[derive(
1318    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1319)]
1320#[non_exhaustive]
1321pub enum DeliveryScope {
1322    /// Authoritative live canonical delivery.
1323    #[default]
1324    Canonical,
1325    /// Authoritative historical/recovery delivery that advances canonical progress.
1326    CanonicalProgress,
1327    /// Historical replay routed to selected owners without changing global chain state.
1328    OwnerCatchup,
1329    /// Ephemeral pre-confirmation delivery applied only to the speculative
1330    /// cache overlay.
1331    Preconfirmed,
1332}
1333
1334impl DeliveryScope {
1335    const fn advances_canonical_state(self) -> bool {
1336        matches!(self, Self::Canonical | Self::CanonicalProgress)
1337    }
1338}
1339
1340/// One input together with its routing and canonical-processing provenance.
1341#[derive(Clone, Debug)]
1342pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1343    record: ReactiveInputRecord<N>,
1344    audience: DeliveryAudience,
1345    scope: DeliveryScope,
1346}
1347
1348impl<N: Network> ReactiveInputDelivery<N> {
1349    /// Construct one lossless delivered record.
1350    pub fn new(
1351        record: ReactiveInputRecord<N>,
1352        audience: DeliveryAudience,
1353        scope: DeliveryScope,
1354    ) -> Self {
1355        Self {
1356            record,
1357            audience,
1358            scope,
1359        }
1360    }
1361
1362    /// Borrow the runtime input record.
1363    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1364        &self.record
1365    }
1366
1367    /// Borrow the exact routing audience.
1368    pub const fn audience(&self) -> &DeliveryAudience {
1369        &self.audience
1370    }
1371
1372    /// Return the record's canonical-processing scope.
1373    pub const fn scope(&self) -> DeliveryScope {
1374        self.scope
1375    }
1376
1377    /// Consume this value into its complete parts.
1378    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1379        (self.record, self.audience, self.scope)
1380    }
1381}
1382
1383/// Complete contents of a consumed [`ReactiveInputBatch`].
1384///
1385/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1386/// discards subscriber commit and chain-lifecycle metadata.
1387#[derive(Clone, Debug)]
1388#[non_exhaustive]
1389pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1390    /// Authoritative chain identity for controls and records in this batch.
1391    pub chain_id: Option<u64>,
1392    /// Records with per-record routing and chain provenance.
1393    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1394    /// Subscriber delivery token committed after ingestion.
1395    pub delivery_token: Option<SubscriberDeliveryToken>,
1396    /// Provider-specific resume cursor associated with the delivery.
1397    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1398    /// Exact opaque wire-payload commitment supplied by the subscriber.
1399    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1400    /// Ordered chain controls sharing the delivery's commit boundary.
1401    pub chain_controls: Vec<ChainControl>,
1402}
1403
1404/// Batch of reactive input records.
1405#[derive(Clone, Debug)]
1406pub struct ReactiveInputBatch<N: Network = Ethereum> {
1407    records: Vec<ReactiveInputRecord<N>>,
1408    chain_id: Option<u64>,
1409    delivery_token: Option<SubscriberDeliveryToken>,
1410    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1411    payload_commitment: Option<SubscriberPayloadCommitment>,
1412    audience: DeliveryAudience,
1413    record_audiences: Option<Vec<DeliveryAudience>>,
1414    delivery_scope: DeliveryScope,
1415    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1416    chain_controls: Vec<ChainControl>,
1417}
1418
1419type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1420
1421impl<N: Network> ReactiveInputBatch<N> {
1422    /// Create a batch from records.
1423    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1424        let chain_id = common_record_chain_id(&records);
1425        Self {
1426            records,
1427            chain_id,
1428            delivery_token: None,
1429            subscriber_checkpoint: None,
1430            payload_commitment: None,
1431            audience: DeliveryAudience::All,
1432            record_audiences: None,
1433            delivery_scope: DeliveryScope::Canonical,
1434            record_delivery_scopes: None,
1435            chain_controls: Vec::new(),
1436        }
1437    }
1438
1439    /// Bind the complete batch, including control-only progress/finality, to a
1440    /// chain. Runtime ingestion rejects a different cache chain.
1441    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1442        self.chain_id = Some(chain_id);
1443        self
1444    }
1445
1446    /// Authoritative batch chain identity, when supplied or unambiguously
1447    /// derived from its records.
1448    pub const fn chain_id(&self) -> Option<u64> {
1449        self.chain_id
1450    }
1451
1452    /// Attach the subscriber-owned token committed after successful ingestion.
1453    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1454        self.delivery_token = Some(token);
1455        self
1456    }
1457
1458    /// Borrow the subscriber-owned delivery token, when present.
1459    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1460        self.delivery_token.as_ref()
1461    }
1462
1463    /// Attach provider-specific resume state included by this delivery.
1464    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1465        self.subscriber_checkpoint = Some(checkpoint);
1466        self
1467    }
1468
1469    /// Borrow provider-specific resume state, when present.
1470    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1471        self.subscriber_checkpoint.as_ref()
1472    }
1473
1474    /// Attach a commitment to the exact canonical wire payload represented by
1475    /// this batch.
1476    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1477        self.payload_commitment = Some(commitment);
1478        self
1479    }
1480
1481    /// Borrow the subscriber-supplied exact payload commitment, when present.
1482    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1483        self.payload_commitment.as_ref()
1484    }
1485
1486    /// Restrict runtime routing to exact logical interest owners.
1487    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1488        self.audience = audience;
1489        self.record_audiences = None;
1490        self
1491    }
1492
1493    /// Delivery audience captured by the subscriber.
1494    pub const fn audience(&self) -> &DeliveryAudience {
1495        &self.audience
1496    }
1497
1498    /// Create a batch whose records retain independent delivery audiences.
1499    pub fn from_scoped_records(
1500        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1501    ) -> Self {
1502        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1503        let chain_id = common_record_chain_id(&records);
1504        Self {
1505            records,
1506            chain_id,
1507            delivery_token: None,
1508            subscriber_checkpoint: None,
1509            payload_commitment: None,
1510            audience: DeliveryAudience::All,
1511            record_audiences: Some(record_audiences),
1512            delivery_scope: DeliveryScope::Canonical,
1513            record_delivery_scopes: None,
1514            chain_controls: Vec::new(),
1515        }
1516    }
1517
1518    /// Create a batch with independent routing and canonical provenance per record.
1519    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1520        Self::from_scoped_records_with_delivery_scope(
1521            deliveries
1522                .into_iter()
1523                .map(ReactiveInputDelivery::into_parts),
1524        )
1525    }
1526
1527    /// Audience for the record at `index`.
1528    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1529        if index >= self.records.len() {
1530            return None;
1531        }
1532        Some(
1533            self.record_audiences
1534                .as_ref()
1535                .and_then(|audiences| audiences.get(index))
1536                .unwrap_or(&self.audience),
1537        )
1538    }
1539
1540    /// Set how every record in this batch participates in canonical state.
1541    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1542        self.delivery_scope = scope;
1543        self.record_delivery_scopes = None;
1544        self
1545    }
1546
1547    /// Canonical-processing scope for the record at `index`.
1548    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
1549        if index >= self.records.len() {
1550            return None;
1551        }
1552        Some(
1553            self.record_delivery_scopes
1554                .as_ref()
1555                .and_then(|scopes| scopes.get(index))
1556                .copied()
1557                .unwrap_or(self.delivery_scope),
1558        )
1559    }
1560
1561    fn from_scoped_records_with_delivery_scope(
1562        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
1563    ) -> Self {
1564        let mut input_records = Vec::new();
1565        let mut audiences = Vec::new();
1566        let mut scopes = Vec::new();
1567        for (record, audience, scope) in records {
1568            input_records.push(record);
1569            audiences.push(audience);
1570            scopes.push(scope);
1571        }
1572        let chain_id = common_record_chain_id(&input_records);
1573        Self {
1574            records: input_records,
1575            chain_id,
1576            delivery_token: None,
1577            subscriber_checkpoint: None,
1578            payload_commitment: None,
1579            audience: DeliveryAudience::All,
1580            record_audiences: Some(audiences),
1581            delivery_scope: DeliveryScope::Canonical,
1582            record_delivery_scopes: Some(scopes),
1583            chain_controls: Vec::new(),
1584        }
1585    }
1586
1587    /// Attach ordered chain-lifecycle controls to this delivery.
1588    ///
1589    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
1590    /// When records are present, their unanimous chain id is derived by the
1591    /// constructor; a missing or cache-mismatched authoritative batch identity
1592    /// is rejected before any control mutates runtime state.
1593    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
1594        self.chain_controls = controls.into_iter().collect();
1595        self
1596    }
1597
1598    /// Ordered chain-lifecycle controls in this delivery.
1599    pub fn chain_controls(&self) -> &[ChainControl] {
1600        &self.chain_controls
1601    }
1602
1603    /// Borrow the records in this batch.
1604    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
1605        &self.records
1606    }
1607
1608    /// Consume the batch into only its input records.
1609    ///
1610    /// This is intentionally lossy: it discards the authoritative batch chain
1611    /// identity, routing audiences, delivery scopes, ordered chain controls,
1612    /// acknowledgement tokens, and provider checkpoints. Adapters should use
1613    /// [`into_parts`](Self::into_parts) instead.
1614    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
1615        self.records
1616    }
1617
1618    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
1619    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
1620        let chain_id = self.chain_id;
1621        let delivery_token = self.delivery_token;
1622        let subscriber_checkpoint = self.subscriber_checkpoint;
1623        let payload_commitment = self.payload_commitment;
1624        let chain_controls = self.chain_controls;
1625        let audiences = self
1626            .record_audiences
1627            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
1628        let scopes = self
1629            .record_delivery_scopes
1630            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
1631        let deliveries = self
1632            .records
1633            .into_iter()
1634            .zip(audiences)
1635            .zip(scopes)
1636            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
1637            .collect();
1638        ReactiveInputBatchParts {
1639            chain_id,
1640            deliveries,
1641            delivery_token,
1642            subscriber_checkpoint,
1643            payload_commitment,
1644            chain_controls,
1645        }
1646    }
1647
1648    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
1649        let audiences = self
1650            .record_audiences
1651            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
1652        let scopes = self
1653            .record_delivery_scopes
1654            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
1655        let records = self
1656            .records
1657            .into_iter()
1658            .zip(audiences)
1659            .zip(scopes)
1660            .map(|((record, audience), scope)| (record, audience, scope))
1661            .collect();
1662        (records, self.chain_controls, self.chain_id)
1663    }
1664
1665    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
1666        self.delivery_token.take()
1667    }
1668
1669    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
1670        self.subscriber_checkpoint.take()
1671    }
1672}
1673
1674fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
1675    let chain_id = records.first()?.context.chain_id?;
1676    records
1677        .iter()
1678        .all(|record| record.context.chain_id == Some(chain_id))
1679        .then_some(chain_id)
1680}
1681
1682/// Pure synchronous handler for reactive inputs.
1683pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
1684    /// Stable handler id.
1685    fn id(&self) -> HandlerId;
1686
1687    /// Interests used by subscribers and the local router.
1688    fn interests(&self) -> Vec<ReactiveInterest<N>>;
1689
1690    /// Exhaustive exact keys for log inputs this handler can accept.
1691    ///
1692    /// Returning `None` keeps the handler on the compatibility fallback path.
1693    /// Returning an index promises that every matching log has at least one of
1694    /// its keys; the registry still re-checks the handler's original
1695    /// [`LogInterest`]s and local matchers before dispatch.
1696    fn log_route_index(&self) -> Option<LogRouteIndex> {
1697        None
1698    }
1699
1700    /// Handle one input against a read-only cache view.
1701    fn handle(
1702        &self,
1703        ctx: &ReactiveContext,
1704        input: &ReactiveInput<N>,
1705        state: &dyn StateView,
1706    ) -> Result<HandlerOutcome, HandlerError>;
1707}
1708
1709/// Hook invoked after reports are built and cache mutation phases have ended.
1710///
1711/// Hooks are synchronous in-process observers, not a durable transactional
1712/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
1713/// back during checkpoint staging, and it dispatches a successfully staged
1714/// batch at most once per live engine. A process crash can still occur between
1715/// hook dispatch and durable checkpoint or transport acknowledgement. External
1716/// side effects therefore need their own idempotency key (normally an
1717/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
1718pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
1719    /// Observe a runtime report.
1720    fn on_report(&self, report: Arc<ReactiveReport<N>>);
1721}
1722
1723/// Reactive subscription interest.
1724#[allow(clippy::large_enum_variant)]
1725#[derive(Clone)]
1726pub enum ReactiveInterest<N: Network = Ethereum> {
1727    /// Log interest.
1728    Logs(LogInterest),
1729    /// Block interest.
1730    Blocks(BlockInterest),
1731    /// Pending transaction interest.
1732    PendingTransactions(PendingTxInterest<N>),
1733}
1734
1735impl<N: Network> fmt::Debug for ReactiveInterest<N> {
1736    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1737        match self {
1738            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
1739            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
1740            Self::PendingTransactions(interest) => f
1741                .debug_tuple("PendingTransactions")
1742                .field(interest)
1743                .finish(),
1744        }
1745    }
1746}
1747
1748/// Interest in logs.
1749#[derive(Clone)]
1750pub struct LogInterest {
1751    /// Provider-side filter.
1752    pub provider_filter: Filter,
1753    /// Optional local matcher for predicates providers cannot express.
1754    pub local_matcher: Option<Arc<dyn LogMatcher>>,
1755    /// Optional route-key extraction strategy.
1756    pub route_key: Option<RouteKeySpec>,
1757}
1758
1759impl LogInterest {
1760    /// Return true if the log matches both the provider filter and local matcher.
1761    pub fn matches(&self, log: &Log) -> bool {
1762        self.provider_filter.rpc_matches(log)
1763            && self
1764                .local_matcher
1765                .as_ref()
1766                .is_none_or(|matcher| matcher.matches(log))
1767    }
1768
1769    /// Extract the route key for a matching log, if configured.
1770    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
1771        self.route_key.as_ref().and_then(|spec| spec.extract(log))
1772    }
1773}
1774
1775impl fmt::Debug for LogInterest {
1776    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1777        f.debug_struct("LogInterest")
1778            .field("provider_filter", &self.provider_filter)
1779            .field(
1780                "local_matcher",
1781                &self.local_matcher.as_ref().map(|_| "<matcher>"),
1782            )
1783            .field("route_key", &self.route_key)
1784            .finish()
1785    }
1786}
1787
1788/// Local log predicate.
1789pub trait LogMatcher: Send + Sync {
1790    /// Return true when the log should be routed to the handler.
1791    fn matches(&self, log: &Log) -> bool;
1792}
1793
1794/// Route-key extraction strategy for logs.
1795#[derive(Clone)]
1796pub enum RouteKeySpec {
1797    /// Route by emitting address.
1798    EmitterAddress,
1799    /// Route by indexed topic.
1800    Topic {
1801        /// Topic index.
1802        index: usize,
1803    },
1804    /// Route by a byte slice in log data.
1805    DataSlice {
1806        /// Byte offset in the data payload.
1807        offset: usize,
1808        /// Number of bytes to copy.
1809        len: usize,
1810    },
1811    /// Custom extractor.
1812    Custom(Arc<dyn RouteKeyExtractor>),
1813}
1814
1815impl RouteKeySpec {
1816    /// Extract a route key from a log.
1817    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
1818        match self {
1819            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
1820            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
1821            Self::DataSlice { offset, len } => {
1822                let data = log.inner.data.data.as_ref();
1823                let end = offset.checked_add(*len)?;
1824                data.get(*offset..end)
1825                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
1826            }
1827            Self::Custom(extractor) => extractor.extract(log),
1828        }
1829    }
1830}
1831
1832impl fmt::Debug for RouteKeySpec {
1833    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1834        match self {
1835            Self::EmitterAddress => f.write_str("EmitterAddress"),
1836            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
1837            Self::DataSlice { offset, len } => f
1838                .debug_struct("DataSlice")
1839                .field("offset", offset)
1840                .field("len", len)
1841                .finish(),
1842            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
1843        }
1844    }
1845}
1846
1847/// Extracts custom route keys from logs.
1848pub trait RouteKeyExtractor: Send + Sync {
1849    /// Extract a route key.
1850    fn extract(&self, log: &Log) -> Option<RouteKey>;
1851}
1852
1853/// Extracted route key.
1854#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1855pub enum RouteKey {
1856    /// Address key.
1857    Address(Address),
1858    /// 32-byte key.
1859    Bytes32(B256),
1860    /// Arbitrary bytes key.
1861    Bytes(Vec<u8>),
1862}
1863
1864/// Exact protocol-neutral key used to select candidate log handlers.
1865#[non_exhaustive]
1866#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1867pub enum LogRouteKey {
1868    /// Emitting contract address.
1869    Emitter(Address),
1870    /// Exact indexed topic.
1871    Topic {
1872        /// Topic position in the log.
1873        index: usize,
1874        /// Expected topic value.
1875        value: B256,
1876    },
1877    /// Exact byte slice in the log data.
1878    DataSlice {
1879        /// Byte offset in the data payload.
1880        offset: usize,
1881        /// Expected bytes.
1882        value: Vec<u8>,
1883    },
1884}
1885
1886/// Non-empty exhaustive OR-set of exact log route keys.
1887#[derive(Clone, Debug, PartialEq, Eq)]
1888pub struct LogRouteIndex {
1889    keys: Vec<LogRouteKey>,
1890}
1891
1892impl LogRouteIndex {
1893    /// Construct an index from one required key and optional additional keys.
1894    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
1895        let mut keys = vec![primary];
1896        for key in additional {
1897            if !keys.contains(&key) {
1898                keys.push(key);
1899            }
1900        }
1901        Self { keys }
1902    }
1903
1904    /// Construct a single-key index.
1905    pub fn single(key: LogRouteKey) -> Self {
1906        Self { keys: vec![key] }
1907    }
1908
1909    /// Exact keys in declaration order.
1910    pub fn keys(&self) -> &[LogRouteKey] {
1911        &self.keys
1912    }
1913}
1914
1915/// Exact log route selected by [`ReactiveRegistry::route_log`].
1916#[derive(Clone, Debug, PartialEq, Eq)]
1917pub struct ReactiveLogRoute {
1918    /// Handler whose log interest matched.
1919    pub handler_id: HandlerId,
1920    /// Optional route key extracted from the matching log interest.
1921    pub route_key: Option<RouteKey>,
1922}
1923
1924/// Interest in block inputs.
1925#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1926pub struct BlockInterest {
1927    /// Block input mode.
1928    pub mode: BlockInterestMode,
1929}
1930
1931impl Default for BlockInterest {
1932    fn default() -> Self {
1933        Self {
1934            mode: BlockInterestMode::Header,
1935        }
1936    }
1937}
1938
1939/// Block subscription mode.
1940#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1941pub enum BlockInterestMode {
1942    /// Header-only block input.
1943    Header,
1944    /// Full block input.
1945    FullBlock,
1946}
1947
1948/// Interest in pending transaction inputs.
1949#[derive(Clone)]
1950pub struct PendingTxInterest<N: Network = Ethereum> {
1951    /// Whether the handler requires full transaction bodies.
1952    pub full_transactions: bool,
1953    /// Sender matcher.
1954    pub from: AddressMatcher,
1955    /// Recipient matcher.
1956    pub to: AddressMatcher,
1957    /// Calldata selector matcher.
1958    pub selectors: SelectorMatcher,
1959    /// Optional local transaction matcher.
1960    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
1961}
1962
1963impl<N: Network> Default for PendingTxInterest<N> {
1964    fn default() -> Self {
1965        Self {
1966            full_transactions: false,
1967            from: AddressMatcher::Any,
1968            to: AddressMatcher::Any,
1969            selectors: SelectorMatcher::Any,
1970            local_matcher: None,
1971        }
1972    }
1973}
1974
1975impl<N: Network> PendingTxInterest<N> {
1976    fn matches_hash_only(&self) -> bool {
1977        !self.full_transactions
1978            && self.from.is_any()
1979            && self.to.is_any()
1980            && self.selectors.is_any()
1981            && self.local_matcher.is_none()
1982    }
1983
1984    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
1985        self.from.matches(tx.from())
1986            && self.to.matches_option(tx.to())
1987            && self.selectors.matches(tx.input())
1988            && self
1989                .local_matcher
1990                .as_ref()
1991                .is_none_or(|matcher| matcher.matches(tx))
1992    }
1993}
1994
1995impl<N: Network> fmt::Debug for PendingTxInterest<N> {
1996    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1997        f.debug_struct("PendingTxInterest")
1998            .field("full_transactions", &self.full_transactions)
1999            .field("from", &self.from)
2000            .field("to", &self.to)
2001            .field("selectors", &self.selectors)
2002            .field(
2003                "local_matcher",
2004                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2005            )
2006            .finish()
2007    }
2008}
2009
2010/// Address matching helper for pending transaction interests.
2011#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2012pub enum AddressMatcher {
2013    /// Match every address.
2014    Any,
2015    /// Match one address.
2016    Exact(Address),
2017    /// Match any address in the list.
2018    AnyOf(Vec<Address>),
2019}
2020
2021impl AddressMatcher {
2022    /// Return true when the matcher is unconstrained.
2023    pub fn is_any(&self) -> bool {
2024        matches!(self, Self::Any)
2025    }
2026
2027    /// Match a present address.
2028    pub fn matches(&self, address: Address) -> bool {
2029        match self {
2030            Self::Any => true,
2031            Self::Exact(expected) => *expected == address,
2032            Self::AnyOf(addresses) => addresses.contains(&address),
2033        }
2034    }
2035
2036    /// Match an optional address.
2037    pub fn matches_option(&self, address: Option<Address>) -> bool {
2038        match (self, address) {
2039            (Self::Any, _) => true,
2040            (_, Some(address)) => self.matches(address),
2041            _ => false,
2042        }
2043    }
2044}
2045
2046/// Calldata selector matching helper.
2047#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2048pub enum SelectorMatcher {
2049    /// Match every selector.
2050    Any,
2051    /// Match any selector in the list.
2052    AnyOf(Vec<[u8; 4]>),
2053}
2054
2055impl SelectorMatcher {
2056    /// Return true when the matcher is unconstrained.
2057    pub fn is_any(&self) -> bool {
2058        matches!(self, Self::Any)
2059    }
2060
2061    /// Match calldata bytes.
2062    pub fn matches(&self, input: &Bytes) -> bool {
2063        match self {
2064            Self::Any => true,
2065            Self::AnyOf(selectors) => input
2066                .get(..4)
2067                .and_then(|bytes| bytes.try_into().ok())
2068                .is_some_and(|selector| selectors.contains(&selector)),
2069        }
2070    }
2071}
2072
2073/// Local predicate over a full pending transaction.
2074pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2075    /// Return true when the transaction should be routed to the handler.
2076    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2077}
2078
2079/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2080///
2081/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2082/// liveness strategy is per-contract:
2083///
2084/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2085///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2086///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2087/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2088///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2089///   each canonical block; a move a decoder did not cover is a coverage gap.
2090///
2091/// A false-positive resync is never *incorrect* — it costs one batched read — so
2092/// the policy is a **pure cost knob**, not a correctness lever.
2093///
2094/// [`Slots`]: TrackingPolicy::Slots
2095/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2096#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2097#[non_exhaustive]
2098pub enum TrackingPolicy {
2099    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2100    /// nearly every block, so it is a noisy gate — this policy is **never**
2101    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2102    /// and cadence reconcile.
2103    Slots {
2104        /// The enumerated storage slots of interest.
2105        slots: Vec<U256>,
2106    },
2107    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2108    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2109    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2110    /// [`ResyncReason::RootMoved`] repair.
2111    WholeAccount,
2112    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2113    /// response's account fields; no storage interest. Native balance/nonce
2114    /// changes do **not** move the storage root, so this policy compares the
2115    /// account fields directly across blocks rather than root-gating.
2116    Scalars,
2117}
2118
2119/// How often the reactive root gate probes tracked accounts
2120/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2121/// `Scalars` account-fields comparison rides the same firing).
2122///
2123/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2124/// is never the default. Skipping blocks is safe by construction: the gate
2125/// diffs `root_now` against its **persisted baseline**, never
2126/// block-over-block, so a move in any skipped block is still visible at the
2127/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2128/// cost, never eventual detection. The decoder-touched set accumulates across
2129/// skipped blocks and drains per firing, so a covered write in a skipped
2130/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2131#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2132pub enum RootGateCadence {
2133    /// Probe at most once every `n` canonical blocks (the first canonical
2134    /// block ever seen always fires, so baseline adoption does not wait a
2135    /// full window). `EveryNBlocks(1)` is per-block probing.
2136    EveryNBlocks(NonZeroU64),
2137    /// Root gate off: coverage gaps surface only via decoders + freshness.
2138    Disabled,
2139}
2140
2141impl RootGateCadence {
2142    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2143    pub fn every_n_blocks(n: u64) -> Self {
2144        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2145    }
2146}
2147
2148impl Default for RootGateCadence {
2149    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2150    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2151    /// `n`, not lower it.
2152    fn default() -> Self {
2153        Self::every_n_blocks(16)
2154    }
2155}
2156
2157/// Per-account baseline held by the root gate: the last observed on-chain root
2158/// and account fields, plus the block they were observed at.
2159///
2160/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2161/// spec §6): it persists the *observed* root as a baseline and compares
2162/// `root_now` to it. This is a currency gate, not a completeness gate.
2163#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2164struct TrackedRoot {
2165    last_root: B256,
2166    last_block: u64,
2167    balance: U256,
2168    nonce: u64,
2169    code_hash: B256,
2170}
2171
2172/// Request for authoritative state repair.
2173#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2174pub struct ResyncRequest {
2175    /// Resync id.
2176    pub id: ResyncId,
2177    /// Reason for the request.
2178    pub reason: ResyncReason,
2179    /// Block selection for the read.
2180    pub block: ResyncBlock,
2181    /// Targets to resync.
2182    pub targets: Vec<ResyncTarget>,
2183    /// Scheduling priority.
2184    pub priority: ResyncPriority,
2185}
2186
2187/// Resync id.
2188#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2189pub struct ResyncId(String);
2190
2191impl ResyncId {
2192    /// Create a resync id.
2193    pub fn new(id: impl Into<String>) -> Self {
2194        Self(id.into())
2195    }
2196}
2197
2198/// Reason for a resync request.
2199#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2200#[non_exhaustive]
2201pub enum ResyncReason {
2202    /// Handler requested repair.
2203    HandlerRequested,
2204    /// State effect could not be applied completely.
2205    SkippedStateEffect,
2206    /// A missed block range was detected; caller-scheduled repair.
2207    ///
2208    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2209    /// range (there are no known targets to resync). This reason is provided so a
2210    /// caller building its own repair in response to a
2211    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2212    MissedBlockRange,
2213    /// A tracked account's storage root moved with no covering decoder.
2214    ///
2215    /// Emitted by the per-block root gate (Phase-8 step 4). A
2216    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2217    /// `storageHash` moved between the adopted baseline and the current canonical
2218    /// block, yet no decoder wrote that account during the block — a coverage gap.
2219    /// The gate schedules a resync with this reason to re-read the account
2220    /// authoritatively and self-heal the blind spot. Also used for the
2221    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2222    RootMoved,
2223    /// Caller-defined reason.
2224    Custom(String),
2225}
2226
2227/// Block target for a resync.
2228#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2229pub enum ResyncBlock {
2230    /// Latest block.
2231    Latest,
2232    /// Current provider pre-confirmation state.
2233    Pending,
2234    /// Safe head.
2235    Safe,
2236    /// Finalized head.
2237    Finalized,
2238    /// Block number.
2239    Number(u64),
2240    /// Block hash and number.
2241    Hash {
2242        /// Block number.
2243        number: u64,
2244        /// Block hash.
2245        hash: B256,
2246        /// Require the hash to still be canonical.
2247        require_canonical: bool,
2248    },
2249}
2250
2251/// State target for a resync.
2252#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2253pub enum ResyncTarget {
2254    /// One storage slot.
2255    StorageSlot {
2256        /// Contract address.
2257        address: Address,
2258        /// Storage slot.
2259        slot: U256,
2260    },
2261    /// Multiple storage slots on one contract.
2262    StorageSlots {
2263        /// Contract address.
2264        address: Address,
2265        /// Storage slots.
2266        slots: Vec<U256>,
2267    },
2268    /// Account fields.
2269    Account {
2270        /// Account address.
2271        address: Address,
2272        /// Fields to resync.
2273        fields: AccountFieldMask,
2274    },
2275}
2276
2277/// Account fields requested by a resync.
2278#[derive(
2279    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2280)]
2281pub struct AccountFieldMask {
2282    /// Balance field.
2283    pub balance: bool,
2284    /// Nonce field.
2285    pub nonce: bool,
2286    /// Code field.
2287    pub code: bool,
2288}
2289
2290/// Resync priority.
2291#[derive(
2292    Clone,
2293    Copy,
2294    Debug,
2295    Default,
2296    PartialEq,
2297    Eq,
2298    Hash,
2299    PartialOrd,
2300    Ord,
2301    serde::Serialize,
2302    serde::Deserialize,
2303)]
2304pub enum ResyncPriority {
2305    /// Low priority.
2306    Low,
2307    /// Normal priority.
2308    #[default]
2309    Normal,
2310    /// High priority.
2311    High,
2312}
2313
2314/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2315#[derive(Clone, Debug, PartialEq, Eq)]
2316pub struct InvalidationRequest {
2317    /// Purge scope.
2318    pub scope: PurgeScope,
2319    /// Address to purge.
2320    pub address: Address,
2321    /// Reason for reporting.
2322    pub reason: InvalidationReason,
2323}
2324
2325/// Invalidation reason.
2326#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2327pub enum InvalidationReason {
2328    /// Handler requested invalidation.
2329    HandlerRequested,
2330    /// Reorg invalidation.
2331    Reorg,
2332    /// Caller-defined reason.
2333    Custom(String),
2334}
2335
2336/// Speculative signal emitted by handlers.
2337#[derive(Clone, Debug, PartialEq, Eq)]
2338pub struct SpeculativeRequest {
2339    /// Speculative request id.
2340    pub id: SpeculativeId,
2341    /// Input that triggered the request.
2342    pub input_ref: InputRef,
2343    /// Labels for downstream routing.
2344    pub labels: Vec<ReportTag>,
2345}
2346
2347/// Speculative request id.
2348#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2349pub struct SpeculativeId(String);
2350
2351impl SpeculativeId {
2352    /// Create a speculative id.
2353    pub fn new(id: impl Into<String>) -> Self {
2354        Self(id.into())
2355    }
2356}
2357
2358/// Configuration for [`ReactiveRuntime`].
2359#[derive(Clone, Debug, PartialEq, Eq)]
2360pub struct ReactiveConfig {
2361    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2362    /// dispatch is synchronous today (every report is delivered to every hook in
2363    /// order), so this field is a no-op placeholder for a future async dispatcher.
2364    /// Setting it to anything other than the default does not change behavior.
2365    pub hook_backpressure: HookBackpressure,
2366    /// Reorg journal depth: the number of recent canonical blocks whose effects
2367    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2368    /// only blocks still resident in the journal can be recovered. A reorg deeper
2369    /// than `journal_depth` recovers the blocks still in the journal and leaves
2370    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2371    /// purged**, so the freshness/validation loop is the only backstop for that
2372    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2373    ///
2374    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2375    /// precisely. When a reorg references a block that is no longer in the journal,
2376    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2377    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2378    /// reorgs, implicit parent replacements, and removed/reorged records whose
2379    /// rollback proof falls outside the retained effect journal are rejected
2380    /// before mutation, durable save, or acknowledgement. Align this depth with
2381    /// the complete reorg horizon promised by the subscriber.
2382    pub journal_depth: usize,
2383}
2384
2385impl Default for ReactiveConfig {
2386    fn default() -> Self {
2387        Self {
2388            hook_backpressure: HookBackpressure::Block,
2389            journal_depth: 64,
2390        }
2391    }
2392}
2393
2394/// Hook backpressure policy.
2395#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2396pub enum HookBackpressure {
2397    /// Block the producer until hooks are accepted.
2398    Block,
2399    /// Drop the newest report under pressure.
2400    DropNewest,
2401    /// Drop the oldest report under pressure.
2402    DropOldest,
2403    /// Return an error under pressure.
2404    Error,
2405}
2406
2407/// Queryable coarse health of the reactive cache.
2408///
2409/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2410/// degraded or unhealthy state when it detects that its recovery guarantees no
2411/// longer hold (for example a reorg that runs deeper than the journal, so some
2412/// dropped effects are neither rolled back nor purged). Later waves report
2413/// missed-range and coverage-gap conditions into the same state machine.
2414#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2415#[non_exhaustive]
2416pub enum CacheHealth {
2417    /// All recovery guarantees hold; the cache is fully self-consistent.
2418    #[default]
2419    Healthy,
2420    /// A recoverable inconsistency was detected (for example under-recovered
2421    /// reorg effects); `since_block` records the block that triggered the
2422    /// transition.
2423    Degraded {
2424        /// Block number at which the degradation was first observed.
2425        since_block: u64,
2426    },
2427    /// A more serious inconsistency was detected; `since_block` records the
2428    /// block that triggered the transition.
2429    Unhealthy {
2430        /// Block number at which the unhealthy condition was first observed.
2431        since_block: u64,
2432    },
2433}
2434
2435/// Point-in-time copy of the reactive runtime's observability counters.
2436///
2437/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2438/// increasing count over the lifetime of the runtime. Counters wired by later
2439/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2440/// tracking) remain zero until those waves land.
2441#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2442#[non_exhaustive]
2443pub struct CacheMetricsSnapshot {
2444    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2445    /// rolled back or purged.
2446    pub deep_reorgs: u64,
2447    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2448    pub reorgs_recovered: u64,
2449    /// Storage resync targets considered by the resync execution pass.
2450    pub resync_requests: u64,
2451    /// Storage resync targets that could not be fetched or applied.
2452    pub resync_failures: u64,
2453    /// Ranges of blocks the runtime detected it did not observe (reserved).
2454    pub missed_ranges: u64,
2455    /// Storage-hash coverage gaps detected (reserved).
2456    pub coverage_gaps: u64,
2457    /// Pending-source inputs that attempted a canonical cache effect.
2458    pub pending_contamination: u64,
2459    /// Verdicts served past their freshness horizon (reserved).
2460    pub stale_verdicts: u64,
2461}
2462
2463/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2464///
2465/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2466/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2467/// into a plain [`CacheMetricsSnapshot`].
2468#[derive(Debug, Default)]
2469struct CacheMetrics {
2470    deep_reorgs: AtomicU64,
2471    reorgs_recovered: AtomicU64,
2472    resync_requests: AtomicU64,
2473    resync_failures: AtomicU64,
2474    missed_ranges: AtomicU64,
2475    coverage_gaps: AtomicU64,
2476    pending_contamination: AtomicU64,
2477    stale_verdicts: AtomicU64,
2478}
2479
2480impl CacheMetrics {
2481    fn snapshot(&self) -> CacheMetricsSnapshot {
2482        CacheMetricsSnapshot {
2483            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2484            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2485            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2486            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2487            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2488            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2489            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2490            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2491        }
2492    }
2493
2494    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2495        self.deep_reorgs
2496            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2497        self.reorgs_recovered
2498            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2499        self.resync_requests
2500            .store(snapshot.resync_requests, Ordering::Relaxed);
2501        self.resync_failures
2502            .store(snapshot.resync_failures, Ordering::Relaxed);
2503        self.missed_ranges
2504            .store(snapshot.missed_ranges, Ordering::Relaxed);
2505        self.coverage_gaps
2506            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2507        self.pending_contamination
2508            .store(snapshot.pending_contamination, Ordering::Relaxed);
2509        self.stale_verdicts
2510            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2511    }
2512}
2513
2514/// Runtime report.
2515#[derive(Clone, Debug)]
2516#[non_exhaustive]
2517pub enum ReactiveReport<N: Network = Ethereum> {
2518    /// Input was accepted after deduplication.
2519    Input(InputReport<N>),
2520    /// Handlers produced outcomes.
2521    Decoded(DecodedReport<N>),
2522    /// Direct state effects were applied.
2523    Applied(AppliedReport<N>),
2524    /// Resync request was scheduled or completed.
2525    Resynced(ResyncReport),
2526    /// Block-level processing completed.
2527    BlockCommitted(BlockReport<N>),
2528    /// Reorg processing report.
2529    Reorg(ReorgReport<N>),
2530    /// Ordered source control accepted by the runtime.
2531    ChainControl(ChainControlReport),
2532    /// A forward gap in the canonical block sequence was detected: blocks between
2533    /// the last-seen head and an arriving block were never observed.
2534    MissedBlockRange(MissedRangeReport<N>),
2535    /// Cache health transitioned between states.
2536    Health(HealthReport<N>),
2537    /// A tracked account's storage root moved with no covering decoder — a
2538    /// coverage gap the per-block root gate detected (Phase-8 step 4).
2539    CoverageGap(CoverageGapReport<N>),
2540    /// Runtime or handler error.
2541    Error(ReactiveErrorReport<N>),
2542}
2543
2544/// Report emitted after an ordered source control is accepted.
2545#[derive(Clone, Debug, PartialEq, Eq)]
2546pub struct ChainControlReport {
2547    /// Control in its original delivery order.
2548    pub control: ChainControl,
2549}
2550
2551/// Input acceptance report.
2552#[derive(Clone, Debug)]
2553pub struct InputReport<N: Network = Ethereum> {
2554    /// Input reference.
2555    pub input_ref: InputRef,
2556    /// Input context.
2557    pub context: ReactiveContext,
2558    /// Provider session that originated the input, when known.
2559    pub provider: Option<ProviderRef>,
2560    /// Network marker.
2561    pub _network: PhantomData<N>,
2562}
2563
2564/// Decoding report.
2565#[derive(Clone, Debug)]
2566pub struct DecodedReport<N: Network = Ethereum> {
2567    /// Input reference.
2568    pub input_ref: InputRef,
2569    /// Handler ids that matched the input.
2570    pub handler_ids: Vec<HandlerId>,
2571    /// Network marker.
2572    pub _network: PhantomData<N>,
2573}
2574
2575/// Applied state report.
2576#[derive(Clone, Debug)]
2577pub struct AppliedReport<N: Network = Ethereum> {
2578    /// Input reference.
2579    pub input_ref: InputRef,
2580    /// Handler that produced the applied effects.
2581    pub handler_id: HandlerId,
2582    /// State effect quality.
2583    pub quality: StateEffectQuality,
2584    /// Labels emitted by the handler.
2585    pub tags: Vec<ReportTag>,
2586    /// Merged state diff from applied updates and invalidations.
2587    pub diff: StateDiff,
2588    /// State updates applied through the cache.
2589    pub state_updates: Vec<StateUpdate>,
2590    /// Invalidation requests lowered to purge updates.
2591    pub invalidations: Vec<InvalidationRequest>,
2592    /// Resync requests surfaced for a scheduler.
2593    pub resyncs: Vec<ResyncRequest>,
2594    /// Speculative requests surfaced for downstream users.
2595    pub speculative: Vec<SpeculativeRequest>,
2596    /// Hook signals emitted by the handler.
2597    pub hook_signals: Vec<HookSignal>,
2598    /// Network marker.
2599    pub _network: PhantomData<N>,
2600}
2601
2602/// Report of the storage resync requests executed during an ingest cycle: the
2603/// requests considered, the authoritative updates built from successful fetches
2604/// (and their applied diff), and any targets that could not be resynced.
2605#[derive(Clone, Debug, Default, PartialEq, Eq)]
2606pub struct ResyncReport {
2607    /// Requests considered by the resync execution pass.
2608    pub requested: Vec<ResyncRequest>,
2609    /// Authoritative state updates built from successful resync fetches.
2610    pub state_updates: Vec<StateUpdate>,
2611    /// Diff returned by applying [`state_updates`](Self::state_updates).
2612    pub diff: StateDiff,
2613    /// Targets that could not be resynced.
2614    pub failed: Vec<ResyncFailure>,
2615}
2616
2617/// One resync target that could not be fetched or applied.
2618#[derive(Clone, Debug, PartialEq, Eq)]
2619pub struct ResyncFailure {
2620    /// Request that produced the failed target.
2621    pub request_id: ResyncId,
2622    /// Block selection used for the failed target.
2623    pub block: ResyncBlock,
2624    /// Target that could not be resynced.
2625    pub target: ResyncTarget,
2626    /// Stable failure classification for retry policy and metrics.
2627    pub kind: ResyncFailureKind,
2628    /// Human-readable failure reason.
2629    pub message: String,
2630}
2631
2632/// Stable classification for a failed resync target.
2633#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2634#[non_exhaustive]
2635pub enum ResyncFailureKind {
2636    /// A storage target could not be fetched because no storage batch fetcher is configured.
2637    MissingStorageFetcher,
2638    /// The storage batch fetcher returned an error for the requested slot.
2639    StorageFetchFailed,
2640    /// The storage batch fetcher did not return a result for the requested slot.
2641    StorageFetchOmitted,
2642    /// An account target could not be fetched because no account proof fetcher is configured.
2643    MissingAccountFetcher,
2644    /// The account proof fetcher returned an error for the requested address.
2645    AccountFetchFailed,
2646    /// The account proof fetcher did not return a result for the requested address.
2647    AccountFetchOmitted,
2648}
2649
2650/// Block processing report.
2651#[derive(Clone, Debug)]
2652pub struct BlockReport<N: Network = Ethereum> {
2653    /// Block reference, when known.
2654    pub block: Option<BlockRef>,
2655    /// Input references committed for the block.
2656    pub inputs: Vec<InputRef>,
2657    /// Network marker.
2658    pub _network: PhantomData<N>,
2659}
2660
2661/// Report of a detected reorg and the recovery it performed: the dropped
2662/// block(s) and inputs, the exact rollback updates applied for reversible dropped
2663/// effects, the conservative purge updates for irreversible ones, the canceled
2664/// hash-pinned resyncs, and why recovery ran.
2665///
2666/// Recovery only covers blocks still resident in the journal. If a reorg runs
2667/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
2668/// appear here and their effects are neither rolled back nor purged (the runtime
2669/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
2670/// backstop for that span. Checkpointed engine ingestion rejects explicit,
2671/// implicit-parent, and removed-log recovery outside the retained journal
2672/// instead of producing and durably acknowledging a partial report.
2673/// Non-checkpointed ingestion still emits this report when no journal entry was
2674/// recoverable; in that case `dropped` identifies the signal/head when known,
2675/// while `dropped_blocks` and rollback effects are empty.
2676#[derive(Clone, Debug)]
2677pub struct ReorgReport<N: Network = Ethereum> {
2678    /// First dropped block, when known.
2679    pub dropped: Option<BlockRef>,
2680    /// Blocks dropped from the journal, in ascending journal order.
2681    pub dropped_blocks: Vec<BlockRef>,
2682    /// Input references that belonged to dropped blocks.
2683    pub dropped_inputs: Vec<InputRef>,
2684    /// Exact rollback updates applied for reversible dropped effects.
2685    pub rollback_updates: Vec<StateUpdate>,
2686    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
2687    pub rollback_diff: StateDiff,
2688    /// Conservative purge updates applied for irreversible dropped effects.
2689    pub purge_updates: Vec<StateUpdate>,
2690    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
2691    pub purge_diff: StateDiff,
2692    /// Hash-pinned pending resync requests canceled because their block was dropped.
2693    pub canceled_resyncs: Vec<ResyncRequest>,
2694    /// Reorg trigger.
2695    pub reason: ReorgReason,
2696    /// Network marker.
2697    pub _network: PhantomData<N>,
2698}
2699
2700/// Reason reorg recovery ran.
2701#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2702pub enum ReorgReason {
2703    /// A provider emitted an Alloy removed log.
2704    RemovedLog,
2705    /// The input context explicitly marked an input as reorged.
2706    ReorgedInput,
2707    /// A canonical block did not connect to the journaled head.
2708    ParentMismatch,
2709    /// A subscriber delivered an explicit canonical branch transition.
2710    Explicit,
2711}
2712
2713/// Report of a forward gap in the canonical block sequence: an arriving block
2714/// whose number is more than one past the last-seen head, so the blocks in
2715/// between were never observed (for example during a subscription disconnect).
2716///
2717/// The arriving block is still accepted and applied — the chain extends — so this
2718/// report only makes the skipped span observable; it does not drop the block. The
2719/// span `from..=to` is inclusive of both endpoints.
2720#[derive(Clone, Debug)]
2721pub struct MissedRangeReport<N: Network = Ethereum> {
2722    /// First skipped block (`last-seen block number + 1`).
2723    pub from: u64,
2724    /// Last skipped block (`arriving block number - 1`).
2725    pub to: u64,
2726    /// The arriving block's number.
2727    pub block: u64,
2728    /// Network marker.
2729    pub _network: PhantomData<N>,
2730}
2731
2732/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
2733/// caused it and delivered to hooks through the normal dispatch path.
2734#[derive(Clone, Debug)]
2735pub struct HealthReport<N: Network = Ethereum> {
2736    /// Health state before the transition.
2737    pub from: CacheHealth,
2738    /// Health state after the transition.
2739    pub to: CacheHealth,
2740    /// Block number associated with the transition, when known.
2741    pub block: Option<u64>,
2742    /// Network marker.
2743    pub _network: PhantomData<N>,
2744}
2745
2746/// Report that a tracked account's storage root moved on a canonical block that
2747/// no decoder covered — a coverage gap surfaced by the per-block root gate
2748/// (Phase-8 step 4).
2749///
2750/// An account's `storageHash` is a collision-resistant commitment over all of its
2751/// storage, so a moved root proves *something* under the account changed. When
2752/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
2753/// batch's touched-address set does not include it, the change arrived through a
2754/// path no decoder observed. The runtime emits this report (delivered through the
2755/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
2756/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
2757/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
2758#[derive(Clone, Debug)]
2759pub struct CoverageGapReport<N: Network = Ethereum> {
2760    /// The tracked account whose root moved with no covering decoder.
2761    pub address: Address,
2762    /// The canonical block number at which the gap was observed.
2763    pub block: u64,
2764    /// Network marker.
2765    pub _network: PhantomData<N>,
2766}
2767
2768/// Report of a non-fatal error surfaced during an ingest cycle, with the
2769/// associated input (when known) and a human-readable message.
2770#[derive(Clone, Debug)]
2771pub struct ReactiveErrorReport<N: Network = Ethereum> {
2772    /// Input associated with the error, when known.
2773    pub input_ref: Option<InputRef>,
2774    /// Error message.
2775    pub message: String,
2776    /// Network marker.
2777    pub _network: PhantomData<N>,
2778}
2779
2780/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
2781/// [`ReactiveRuntime::ingest_batch_with_resync`].
2782#[derive(Clone, Debug)]
2783pub struct ReactiveBatchReport<N: Network = Ethereum> {
2784    /// Applied reports in commit order.
2785    pub applied: Vec<AppliedReport<N>>,
2786    /// Resync requests surfaced during the batch.
2787    pub resyncs: Vec<ResyncRequest>,
2788    /// Speculative requests surfaced during the batch.
2789    pub speculative: Vec<SpeculativeRequest>,
2790    /// Hook reports dispatched after mutation phases.
2791    pub reports: Vec<Arc<ReactiveReport<N>>>,
2792}
2793
2794impl<N: Network> Default for ReactiveBatchReport<N> {
2795    fn default() -> Self {
2796        Self {
2797            applied: Vec::new(),
2798            resyncs: Vec::new(),
2799            speculative: Vec::new(),
2800            reports: Vec::new(),
2801        }
2802    }
2803}
2804
2805/// Error returned by a handler.
2806#[derive(Clone, Debug, PartialEq, Eq)]
2807pub struct HandlerError {
2808    message: String,
2809}
2810
2811impl HandlerError {
2812    /// Create a handler error from a message.
2813    pub fn new(message: impl Into<String>) -> Self {
2814        Self {
2815            message: message.into(),
2816        }
2817    }
2818}
2819
2820impl fmt::Display for HandlerError {
2821    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2822        self.message.fmt(f)
2823    }
2824}
2825
2826impl std::error::Error for HandlerError {}
2827
2828impl From<String> for HandlerError {
2829    fn from(message: String) -> Self {
2830        Self::new(message)
2831    }
2832}
2833
2834impl From<&str> for HandlerError {
2835    fn from(message: &str) -> Self {
2836        Self::new(message)
2837    }
2838}
2839
2840/// Runtime error.
2841#[derive(Debug, thiserror::Error)]
2842#[non_exhaustive]
2843pub enum ReactiveError {
2844    /// Handler returned an error.
2845    #[error("handler `{handler_id}` failed: {source}")]
2846    HandlerFailed {
2847        /// Handler id.
2848        handler_id: HandlerId,
2849        /// Handler error.
2850        source: HandlerError,
2851    },
2852    /// Multiple handlers emitted incompatible absolute writes for one input.
2853    #[error(
2854        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
2855    )]
2856    ConflictingEffects {
2857        /// Input reference.
2858        input_ref: Box<InputRef>,
2859        /// Conflicting target.
2860        target: Box<EffectTarget>,
2861        /// First handler id.
2862        first: HandlerId,
2863        /// Second handler id.
2864        second: HandlerId,
2865    },
2866    /// Pending inputs attempted to mutate canonical cache state.
2867    #[error(
2868        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
2869    )]
2870    InvalidPendingEffect {
2871        /// Input reference.
2872        input_ref: Box<InputRef>,
2873        /// Handler id.
2874        handler_id: HandlerId,
2875        /// Effect kind.
2876        effect_kind: &'static str,
2877    },
2878    /// A subscriber supplied payload metadata that is incomplete or
2879    /// contradicts the accompanying context.
2880    #[error("invalid reactive input record: {message}")]
2881    InvalidInputRecord {
2882        /// Human-readable invariant violation.
2883        message: String,
2884    },
2885    /// A source delivered a contradictory chain-lifecycle transition.
2886    #[error("invalid chain control: {message}")]
2887    InvalidChainControl {
2888        /// Human-readable invariant violation.
2889        message: String,
2890    },
2891    /// Owner-scoped catch-up would mutate a historical block for which the
2892    /// runtime has no rollback journal entry.
2893    #[error(
2894        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
2895    )]
2896    OwnerCatchupOutsideJournal {
2897        /// Catch-up block number.
2898        number: u64,
2899        /// Catch-up block hash.
2900        hash: B256,
2901    },
2902    /// Registration error.
2903    #[error(transparent)]
2904    Register(#[from] RegisterError),
2905}
2906
2907/// Handler registration error.
2908#[derive(Debug, thiserror::Error)]
2909#[non_exhaustive]
2910pub enum RegisterError {
2911    /// Duplicate handler id.
2912    #[error("handler id `{0}` is already registered")]
2913    DuplicateHandler(HandlerId),
2914}
2915
2916/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
2917/// runtime and subscriber sides.
2918#[derive(Debug, thiserror::Error)]
2919#[non_exhaustive]
2920pub enum ReactiveEngineRegisterError {
2921    /// Runtime registry rejected the handler.
2922    #[error(transparent)]
2923    Register(#[from] RegisterError),
2924    /// Subscriber rejected the handler's interests.
2925    #[error(transparent)]
2926    Subscriber(#[from] SubscriberError),
2927    /// Owner-only history was not constrained to one hash-certified block that
2928    /// remains in the runtime rollback journal.
2929    #[error(
2930        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
2931    )]
2932    BackfillOutsideJournal {
2933        /// First requested block.
2934        start_block: u64,
2935        /// Inclusive requested upper bound, if bounded.
2936        end_block: Option<u64>,
2937        /// Hash-certified anchor supplied by the caller, if any.
2938        retained_anchor: Option<BlockRef>,
2939    },
2940}
2941
2942/// Error adopting an RPC snapshot as a runtime's canonical continuity
2943/// baseline.
2944#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
2945#[non_exhaustive]
2946pub enum ReactiveBaselineError {
2947    /// Runtime or engine delivery state already contains lifecycle work.
2948    #[error("cannot adopt a canonical baseline after reactive processing has started")]
2949    ActiveRuntime,
2950    /// An exact repeat is allowed, but the requested baseline conflicts with
2951    /// the previously adopted block.
2952    #[error(
2953        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
2954    )]
2955    ConflictingBaseline {
2956        /// Existing baseline number.
2957        existing_number: u64,
2958        /// Existing baseline hash.
2959        existing_hash: B256,
2960        /// Requested baseline number.
2961        requested_number: u64,
2962        /// Requested baseline hash.
2963        requested_hash: B256,
2964    },
2965    /// Typed baseline and cache identify different chains.
2966    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
2967    CacheChainMismatch {
2968        /// Chain declared by the baseline.
2969        baseline_chain_id: u64,
2970        /// Chain configured on the cache.
2971        cache_chain_id: u64,
2972    },
2973    /// The cache is not hash-pinned to the exact adopted canonical block.
2974    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
2975    CacheBlockMismatch {
2976        /// Expected baseline number.
2977        number: u64,
2978        /// Expected baseline hash.
2979        hash: B256,
2980    },
2981}
2982
2983/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
2984/// and runtime ingestion.
2985#[derive(Debug, thiserror::Error)]
2986#[non_exhaustive]
2987pub enum ReactiveEngineError {
2988    /// Subscriber polling failed.
2989    #[error(transparent)]
2990    Subscriber(#[from] SubscriberError),
2991    /// Runtime ingestion failed.
2992    #[error(transparent)]
2993    Runtime(ReactiveError),
2994    /// Canonical cold-start baseline adoption failed.
2995    #[error(transparent)]
2996    Baseline(#[from] ReactiveBaselineError),
2997    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
2998    /// did not commit. The subscriber may replay the batch.
2999    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3000    Acknowledgement(#[source] SubscriberError),
3001    /// Runtime ingestion succeeded, but the resulting cache state could not be
3002    /// durably checkpointed. The engine retains the commit in memory and must
3003    /// retry it before polling another batch.
3004    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3005    Checkpoint(#[source] DurableCheckpointError),
3006    /// A checkpointed ingest had no canonical block to bind the state to.
3007    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3008    MissingCheckpointBlock,
3009    /// Speculative pre-confirmation state is intentionally excluded from
3010    /// canonical durable checkpoints.
3011    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3012    PreconfirmationNotCheckpointable,
3013    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3014    #[error("failed to encode durable reactive runtime state: {0}")]
3015    RuntimeCheckpoint(String),
3016    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3017    /// to ordinary acknowledgement ordering without first completing it.
3018    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3019    PendingCheckpointCommit,
3020    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3021    /// switch to checkpointed ingestion and retroactively make it durable.
3022    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3023    PendingAcknowledgementCommit,
3024    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3025    /// commit metadata. Only the combined polling helpers can preserve the
3026    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3027    #[error(
3028        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3029    )]
3030    UncommittedDeliveryMetadata,
3031    /// Subscriber and cache are bound to different chains.
3032    #[error(
3033        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3034    )]
3035    SubscriberChainMismatch {
3036        /// Chain reported by the subscriber.
3037        subscriber_chain_id: u64,
3038        /// Chain configured on the cache.
3039        cache_chain_id: u64,
3040    },
3041    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3042    #[error("subscriber does not advertise durable replay support")]
3043    SubscriberNotDurable,
3044    /// A restored delivery token predates or otherwise lacks the core witness
3045    /// needed to prove that a replay carries the same delivery.
3046    #[error(
3047        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3048    )]
3049    MissingReplayWitness,
3050    /// A source reused a committed token for different records, routing,
3051    /// controls, chain identity, or provider resume state.
3052    #[error("replayed delivery token does not match its committed delivery witness")]
3053    ReplayDeliveryMismatch,
3054    /// The stable delivery witness could not be encoded.
3055    #[error("failed to encode durable delivery witness: {0}")]
3056    DeliveryWitness(String),
3057    /// A tokened network-generic header/body cannot be witnessed completely
3058    /// without a source-supplied canonical wire commitment.
3059    #[error(
3060        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3061    )]
3062    MissingPayloadCommitment,
3063    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3064    /// would bind those unrelated mutations to the older delivery metadata.
3065    #[error(
3066        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3067    )]
3068    PendingCheckpointCacheChanged {
3069        /// Generation immediately after the staged batch was ingested.
3070        staged_generation: u64,
3071        /// Generation observed when checkpoint commit was retried.
3072        current_generation: u64,
3073    },
3074    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3075    /// runtime no longer retains every potentially affected journal entry.
3076    #[error(
3077        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3078    )]
3079    CheckpointReorgOutsideJournal {
3080        /// Last block shared by the old and replacement branches.
3081        common_ancestor: u64,
3082        /// Oldest retained effect-bearing journal block, if any.
3083        oldest_journaled: Option<u64>,
3084        /// Configured maximum journal entries.
3085        journal_depth: usize,
3086    },
3087    /// Owner-scoped catch-up would mutate a historical block for which the
3088    /// runtime has no rollback journal entry.
3089    #[error(
3090        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3091    )]
3092    OwnerCatchupOutsideJournal {
3093        /// Catch-up block number.
3094        number: u64,
3095        /// Catch-up block hash.
3096        hash: B256,
3097    },
3098}
3099
3100impl From<ReactiveError> for ReactiveEngineError {
3101    fn from(error: ReactiveError) -> Self {
3102        match error {
3103            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3104                Self::OwnerCatchupOutsideJournal { number, hash }
3105            }
3106            error => Self::Runtime(error),
3107        }
3108    }
3109}
3110
3111/// Error restoring a durable checkpoint anchor into an active runtime.
3112#[derive(Debug, thiserror::Error)]
3113#[non_exhaustive]
3114pub enum ReactiveCheckpointRestoreError {
3115    /// A runtime with canonical journal state cannot be silently rewound.
3116    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3117    ActiveRuntime,
3118    /// Stored runtime recovery bytes were malformed or unsupported.
3119    #[error("invalid durable reactive runtime state: {0}")]
3120    InvalidRuntimeCheckpoint(String),
3121    /// Checkpoint identity or cache restoration failed before activation.
3122    #[error(transparent)]
3123    Checkpoint(#[from] DurableCheckpointError),
3124    /// Subscriber rejected the restored durable cursor or canonical position.
3125    #[error("subscriber rejected durable resume position: {0}")]
3126    Subscriber(#[source] SubscriberError),
3127    /// Subscriber and checkpoint identities name different chains.
3128    #[error(
3129        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3130    )]
3131    SubscriberChainMismatch {
3132        /// Chain reported by the subscriber.
3133        subscriber_chain_id: u64,
3134        /// Chain committed by the checkpoint identity.
3135        checkpoint_chain_id: u64,
3136    },
3137    /// Restoring event continuity requires a durable replay-capable subscriber.
3138    #[error("subscriber does not advertise durable replay support")]
3139    SubscriberNotDurable,
3140}
3141
3142/// Result of one crash-safe subscriber ingest cycle.
3143#[derive(Clone, Debug)]
3144#[non_exhaustive]
3145pub enum CheckpointedIngest<N: Network = Ethereum> {
3146    /// A new batch was ingested, durably checkpointed, and acknowledged.
3147    Applied(ReactiveBatchReport<N>),
3148    /// The checkpoint already contained this replayed delivery token, so the
3149    /// batch was acknowledged without applying its effects twice.
3150    ReplayAcknowledged,
3151}
3152
3153/// Absolute write target used for conflict reports.
3154#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3155pub enum EffectTarget {
3156    /// Storage slot target.
3157    StorageSlot {
3158        /// Contract address.
3159        address: Address,
3160        /// Storage slot.
3161        slot: U256,
3162    },
3163    /// Account balance target.
3164    AccountBalance {
3165        /// Account address.
3166        address: Address,
3167    },
3168    /// Account nonce target.
3169    AccountNonce {
3170        /// Account address.
3171        address: Address,
3172    },
3173    /// Account code target.
3174    AccountCode {
3175        /// Account address.
3176        address: Address,
3177    },
3178    /// Masked storage slot target.
3179    MaskedStorageSlot {
3180        /// Contract address.
3181        address: Address,
3182        /// Storage slot.
3183        slot: U256,
3184        /// Bit mask.
3185        mask: U256,
3186    },
3187}
3188
3189#[derive(Clone, Debug, PartialEq, Eq)]
3190enum AbsoluteValue {
3191    U256(U256),
3192    U64(u64),
3193    Bytes(Bytes),
3194}
3195
3196/// Reactive runtime.
3197pub struct ReactiveRuntime<N: Network = Ethereum> {
3198    registry: ReactiveRegistry<N>,
3199    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3200    config: ReactiveConfig,
3201    journal: VecDeque<BlockJournal<N>>,
3202    coverage_head: Option<BlockRef>,
3203    pending_resyncs: Vec<ResyncRequest>,
3204    health: CacheHealth,
3205    safe_head: Option<BlockRef>,
3206    finalized_head: Option<BlockRef>,
3207    metrics: CacheMetrics,
3208    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3209    ///
3210    /// `None` by default (behavior unchanged); populated by
3211    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3212    /// present, applying a canonical handler storage-slot effect stamps the
3213    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3214    /// so event-maintained slots stop being needlessly re-verified while aging to
3215    /// volatile once the clock passes `N`.
3216    freshness: Option<FreshnessRegistry>,
3217    /// Per-account tracking registry consulted by the per-block root gate
3218    /// (Phase-8 step 4). Empty by default; populated by
3219    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3220    tracking: HashMap<Address, TrackingPolicy>,
3221    /// Per-account root/field baselines the gate diffs against across blocks.
3222    /// Adopted on first probe and re-adopted on every observed move.
3223    tracked_roots: HashMap<Address, TrackedRoot>,
3224    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3225    root_gate_cadence: RootGateCadence,
3226    /// Canonical block of the last root-gate firing. `None` until the first
3227    /// firing (which happens at the first canonical block ever seen, so
3228    /// baseline adoption never waits a full cadence window).
3229    last_gate_block: Option<u64>,
3230    /// Union of decoder-touched addresses since the last root-gate firing,
3231    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3232    /// touched" must judge against every covered write in the window, or a
3233    /// decoder-covered write in a skipped block would false-positive as a
3234    /// [`ReactiveReport::CoverageGap`].
3235    touched_since_gate: HashSet<Address>,
3236    /// Disposable pre-confirmation branch layered over the canonical cache.
3237    /// This is deliberately omitted from durable runtime checkpoints.
3238    preconfirmed_branch: Option<PreconfirmedBranch>,
3239}
3240
3241#[derive(Clone)]
3242struct PreconfirmedBranch {
3243    flashblock: FlashblockRef,
3244    canonical_cache: EvmCacheStateSnapshot,
3245}
3246
3247#[derive(Clone, Debug)]
3248struct BlockJournal<N: Network = Ethereum> {
3249    block: BlockRef,
3250    inputs: Vec<InputRef>,
3251    applied: Vec<AppliedReport<N>>,
3252    handler_ids: Vec<HandlerId>,
3253    resynced: Vec<ResyncReport>,
3254    rollback_diffs: Vec<StateDiff>,
3255}
3256
3257const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3;
3258
3259#[derive(serde::Serialize, serde::Deserialize)]
3260struct DurableRuntimeCheckpoint {
3261    version: u32,
3262    safe_head: Option<BlockRef>,
3263    finalized_head: Option<BlockRef>,
3264    health: CacheHealth,
3265    pending_resyncs: Vec<ResyncRequest>,
3266    coverage_head: Option<BlockRef>,
3267    journal: Vec<DurableBlockJournal>,
3268    freshness: Option<FreshnessRegistry>,
3269    tracking: HashMap<Address, TrackingPolicy>,
3270    tracked_roots: HashMap<Address, TrackedRoot>,
3271    root_gate_cadence: RootGateCadence,
3272    last_gate_block: Option<u64>,
3273    touched_since_gate: HashSet<Address>,
3274    metrics: CacheMetricsSnapshot,
3275}
3276
3277#[derive(serde::Serialize, serde::Deserialize)]
3278struct DurableBlockJournal {
3279    block: BlockRef,
3280    handler_ids: Vec<HandlerId>,
3281    rollback_diffs: Vec<StateDiff>,
3282}
3283
3284struct DurableRuntimeRestorePlan {
3285    checkpoint: Option<DurableRuntimeCheckpoint>,
3286    fallback_history: Vec<BlockRef>,
3287}
3288
3289impl DurableRuntimeRestorePlan {
3290    fn canonical_history(&self) -> Vec<BlockRef> {
3291        self.checkpoint.as_ref().map_or_else(
3292            || self.fallback_history.clone(),
3293            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3294        )
3295    }
3296}
3297
3298#[derive(Clone)]
3299struct ReactiveRuntimeState<N: Network> {
3300    journal: VecDeque<BlockJournal<N>>,
3301    coverage_head: Option<BlockRef>,
3302    pending_resyncs: Vec<ResyncRequest>,
3303    health: CacheHealth,
3304    safe_head: Option<BlockRef>,
3305    finalized_head: Option<BlockRef>,
3306    freshness: Option<FreshnessRegistry>,
3307    tracking: HashMap<Address, TrackingPolicy>,
3308    tracked_roots: HashMap<Address, TrackedRoot>,
3309    root_gate_cadence: RootGateCadence,
3310    last_gate_block: Option<u64>,
3311    touched_since_gate: HashSet<Address>,
3312    metrics: CacheMetricsSnapshot,
3313}
3314
3315#[derive(Clone)]
3316struct ChainControlState {
3317    journal_invalidated_from: Option<u64>,
3318    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3319}
3320
3321/// Canonical branch fragments already rolled back by the current atomic batch.
3322///
3323/// Providers commonly emit one removed notification per log after one signal
3324/// has already drained the complete dropped block (and every retained
3325/// descendant). Explicit reorg controls can be followed by the same redundant
3326/// lifecycle records. Exact identities decide whether removal recovery is
3327/// redundant; numeric spans are retained only as same-batch proof for a
3328/// parentless replacement after those exact journal entries were drained.
3329#[derive(Default)]
3330struct BatchDroppedCanonical {
3331    identities: HashSet<(u64, B256)>,
3332    implicit_spans: Vec<(u64, u64)>,
3333}
3334
3335impl BatchDroppedCanonical {
3336    fn covers_implicit_number(&self, number: u64) -> bool {
3337        self.implicit_spans
3338            .iter()
3339            .any(|(from, through)| number >= *from && number <= *through)
3340    }
3341
3342    fn contains(&self, block: &BlockRef) -> bool {
3343        self.identities.contains(&(block.number, block.hash))
3344    }
3345
3346    fn record_identity(&mut self, block: &BlockRef) {
3347        self.identities.insert((block.number, block.hash));
3348    }
3349
3350    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3351        self.identities.insert((old_tip.number, old_tip.hash));
3352    }
3353
3354    fn record_drained(&mut self, blocks: &[BlockRef]) {
3355        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3356            return;
3357        };
3358        let through = blocks
3359            .iter()
3360            .map(|block| block.number)
3361            .max()
3362            .expect("a non-empty drained set has a maximum");
3363        self.implicit_spans.push((from, through));
3364        self.identities
3365            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3366    }
3367}
3368
3369/// Registry and router for provider-neutral reactive handlers.
3370///
3371/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3372/// consolidated provider-side log filters for subscription setup, and routes
3373/// provider logs back to the exact matching log interests. Consolidated filters
3374/// may be safe supersets; [`Self::route_log`] always re-checks the original
3375/// [`LogInterest`] and its local matcher before returning a route.
3376pub struct ReactiveRegistry<N: Network = Ethereum> {
3377    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3378    handler_positions: HashMap<HandlerId, u128>,
3379    next_handler_position: u128,
3380    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3381    fallback_log_handlers: BTreeSet<u128>,
3382    data_slice_shapes: HashMap<(usize, usize), usize>,
3383}
3384
3385struct RegisteredHandler<N: Network = Ethereum> {
3386    id: HandlerId,
3387    handler: Arc<dyn ReactiveHandler<N>>,
3388    interests: Vec<ReactiveInterest<N>>,
3389    has_log_interests: bool,
3390    log_route_index: Option<LogRouteIndex>,
3391}
3392
3393impl<N: Network> Default for ReactiveRegistry<N> {
3394    fn default() -> Self {
3395        Self::new()
3396    }
3397}
3398
3399impl<N: Network> ReactiveRegistry<N> {
3400    /// Create an empty registry.
3401    pub fn new() -> Self {
3402        Self {
3403            handlers: BTreeMap::new(),
3404            handler_positions: HashMap::new(),
3405            next_handler_position: 0,
3406            indexed_log_handlers: HashMap::new(),
3407            fallback_log_handlers: BTreeSet::new(),
3408            data_slice_shapes: HashMap::new(),
3409        }
3410    }
3411
3412    /// Register a handler, preserving registration order.
3413    ///
3414    /// Duplicate handler ids are rejected with
3415    /// [`RegisterError::DuplicateHandler`].
3416    ///
3417    /// # Errors
3418    ///
3419    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3420    /// registered.
3421    pub fn register_handler(
3422        &mut self,
3423        handler: Arc<dyn ReactiveHandler<N>>,
3424    ) -> Result<(), RegisterError> {
3425        let id = handler.id();
3426        if self.handler_positions.contains_key(&id) {
3427            return Err(RegisterError::DuplicateHandler(id));
3428        }
3429        let interests = handler.interests();
3430        self.insert_handler_prepared(id, handler, interests);
3431        Ok(())
3432    }
3433
3434    fn insert_handler_prepared(
3435        &mut self,
3436        id: HandlerId,
3437        handler: Arc<dyn ReactiveHandler<N>>,
3438        interests: Vec<ReactiveInterest<N>>,
3439    ) {
3440        debug_assert!(!self.handler_positions.contains_key(&id));
3441        let has_log_interests = interests
3442            .iter()
3443            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3444        let log_route_index = handler.log_route_index();
3445        if self.next_handler_position == u128::MAX {
3446            self.compact_handler_positions();
3447        }
3448        let position = self.next_handler_position;
3449        self.next_handler_position += 1;
3450        self.handler_positions.insert(id.clone(), position);
3451        if let Some(index) = &log_route_index {
3452            for key in index.keys() {
3453                if let LogRouteKey::DataSlice { offset, value } = key {
3454                    *self
3455                        .data_slice_shapes
3456                        .entry((*offset, value.len()))
3457                        .or_default() += 1;
3458                }
3459                self.indexed_log_handlers
3460                    .entry(key.clone())
3461                    .or_default()
3462                    .insert(position);
3463            }
3464        } else if has_log_interests {
3465            self.fallback_log_handlers.insert(position);
3466        }
3467        self.handlers.insert(
3468            position,
3469            RegisteredHandler {
3470                id,
3471                handler,
3472                interests,
3473                has_log_interests,
3474                log_route_index,
3475            },
3476        );
3477    }
3478
3479    /// Remove one handler by id, leaving all other handlers and interests intact.
3480    ///
3481    /// Returns the removed handler when the id was registered. Cache eviction is
3482    /// intentionally outside this API: unregistering stops future routing and
3483    /// decode for the handler only.
3484    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3485        let position = self.handler_positions.remove(id)?;
3486        let registered = self.handlers.remove(&position)?;
3487        if let Some(index) = &registered.log_route_index {
3488            for key in index.keys() {
3489                let remove_bucket = self
3490                    .indexed_log_handlers
3491                    .get_mut(key)
3492                    .is_some_and(|owners| {
3493                        owners.remove(&position);
3494                        owners.is_empty()
3495                    });
3496                if remove_bucket {
3497                    self.indexed_log_handlers.remove(key);
3498                }
3499                if let LogRouteKey::DataSlice { offset, value } = key {
3500                    let shape = (*offset, value.len());
3501                    let remove_shape =
3502                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3503                            *count -= 1;
3504                            *count == 0
3505                        });
3506                    if remove_shape {
3507                        self.data_slice_shapes.remove(&shape);
3508                    }
3509                }
3510            }
3511        } else {
3512            self.fallback_log_handlers.remove(&position);
3513        }
3514        Some(registered.handler)
3515    }
3516
3517    /// Return true when `id` is currently registered.
3518    pub fn contains_handler(&self, id: &HandlerId) -> bool {
3519        self.handler_positions.contains_key(id)
3520    }
3521
3522    /// Ids of all registered handlers, in registration (= routing) order.
3523    pub fn handler_ids(&self) -> Vec<HandlerId> {
3524        self.handlers
3525            .values()
3526            .map(|handler| handler.id.clone())
3527            .collect()
3528    }
3529
3530    /// Borrow the interests owned by one handler.
3531    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
3532        self.handler_positions
3533            .get(id)
3534            .and_then(|position| self.handlers.get(position))
3535            .map(|registered| registered.interests.as_slice())
3536    }
3537
3538    /// Return all registered interests in handler registration order.
3539    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
3540        self.handlers
3541            .values()
3542            .flat_map(|handler| handler.interests.clone())
3543            .collect()
3544    }
3545
3546    /// Return consolidated provider-side log filters.
3547    ///
3548    /// Filters are emitted in deterministic first-registration order by
3549    /// compatible block option. Within each returned filter, address and topic
3550    /// sets are unioned independently, which can intentionally overfetch. Use
3551    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
3552    pub fn log_subscription_filters(&self) -> Vec<Filter> {
3553        let mut filters = Vec::new();
3554        for interest in self.log_interests() {
3555            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
3556        }
3557        filters
3558    }
3559
3560    /// Route a log to exact matching handler interests.
3561    ///
3562    /// Routes are returned in handler registration order. Each handler appears
3563    /// at most once for a log, using the first matching log interest declared by
3564    /// that handler.
3565    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
3566        self.log_handler_candidates(log)
3567            .into_iter()
3568            .filter_map(|handler| handler.route_log(log))
3569            .collect()
3570    }
3571
3572    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
3573        let mut indexed_positions = Vec::new();
3574        if let Some(indexed) = self
3575            .indexed_log_handlers
3576            .get(&LogRouteKey::Emitter(log.address()))
3577        {
3578            indexed_positions.extend(indexed.iter().copied());
3579        }
3580        for (index, value) in log.topics().iter().copied().enumerate() {
3581            if let Some(indexed) = self
3582                .indexed_log_handlers
3583                .get(&LogRouteKey::Topic { index, value })
3584            {
3585                indexed_positions.extend(indexed.iter().copied());
3586            }
3587        }
3588        let data = log.inner.data.data.as_ref();
3589        for &(offset, len) in self.data_slice_shapes.keys() {
3590            let Some(end) = offset.checked_add(len) else {
3591                continue;
3592            };
3593            let Some(value) = data.get(offset..end) else {
3594                continue;
3595            };
3596            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
3597                offset,
3598                value: value.to_vec(),
3599            }) {
3600                indexed_positions.extend(indexed.iter().copied());
3601            }
3602        }
3603        if indexed_positions.is_empty() {
3604            if self.fallback_log_handlers.is_empty() {
3605                return Vec::new();
3606            }
3607            if !self.indexed_log_handlers.is_empty() {
3608                return self
3609                    .fallback_log_handlers
3610                    .iter()
3611                    .filter_map(|position| self.handlers.get(position))
3612                    .collect();
3613            }
3614            return self
3615                .handlers
3616                .values()
3617                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
3618                .collect();
3619        }
3620
3621        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
3622        indexed_positions.sort_unstable();
3623        indexed_positions.dedup();
3624        indexed_positions
3625            .into_iter()
3626            .filter_map(|position| self.handlers.get(&position))
3627            .collect()
3628    }
3629
3630    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
3631        self.handlers.values()
3632    }
3633
3634    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
3635        self.handlers.values().flat_map(|handler| {
3636            handler
3637                .interests
3638                .iter()
3639                .filter_map(|interest| match interest {
3640                    ReactiveInterest::Logs(interest) => Some(interest),
3641                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
3642                })
3643        })
3644    }
3645
3646    fn compact_handler_positions(&mut self) {
3647        let handlers = std::mem::take(&mut self.handlers);
3648        self.handler_positions.clear();
3649        self.indexed_log_handlers.clear();
3650        self.fallback_log_handlers.clear();
3651        self.data_slice_shapes.clear();
3652
3653        for (position, (_, handler)) in handlers.into_iter().enumerate() {
3654            let position = position as u128;
3655            self.handler_positions.insert(handler.id.clone(), position);
3656            if let Some(index) = &handler.log_route_index {
3657                for key in index.keys() {
3658                    if let LogRouteKey::DataSlice { offset, value } = key {
3659                        *self
3660                            .data_slice_shapes
3661                            .entry((*offset, value.len()))
3662                            .or_default() += 1;
3663                    }
3664                    self.indexed_log_handlers
3665                        .entry(key.clone())
3666                        .or_default()
3667                        .insert(position);
3668                }
3669            } else if handler.has_log_interests {
3670                self.fallback_log_handlers.insert(position);
3671            }
3672            self.handlers.insert(position, handler);
3673        }
3674        self.next_handler_position = self.handlers.len() as u128;
3675    }
3676}
3677
3678impl<N: Network> ReactiveRuntime<N> {
3679    /// Create an empty runtime.
3680    pub fn new(config: ReactiveConfig) -> Self {
3681        Self {
3682            registry: ReactiveRegistry::new(),
3683            hooks: Vec::new(),
3684            config,
3685            journal: VecDeque::new(),
3686            coverage_head: None,
3687            pending_resyncs: Vec::new(),
3688            health: CacheHealth::Healthy,
3689            safe_head: None,
3690            finalized_head: None,
3691            metrics: CacheMetrics::default(),
3692            freshness: None,
3693            tracking: HashMap::new(),
3694            tracked_roots: HashMap::new(),
3695            root_gate_cadence: RootGateCadence::default(),
3696            last_gate_block: None,
3697            touched_since_gate: HashSet::new(),
3698            preconfirmed_branch: None,
3699        }
3700    }
3701
3702    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
3703        ReactiveRuntimeState {
3704            journal: self.journal.clone(),
3705            coverage_head: self.coverage_head,
3706            pending_resyncs: self.pending_resyncs.clone(),
3707            health: self.health,
3708            safe_head: self.safe_head,
3709            finalized_head: self.finalized_head,
3710            freshness: self.freshness.clone(),
3711            tracking: self.tracking.clone(),
3712            tracked_roots: self.tracked_roots.clone(),
3713            root_gate_cadence: self.root_gate_cadence,
3714            last_gate_block: self.last_gate_block,
3715            touched_since_gate: self.touched_since_gate.clone(),
3716            metrics: self.metrics.snapshot(),
3717        }
3718    }
3719
3720    fn is_pristine_for_checkpoint_restore(&self) -> bool {
3721        self.preconfirmed_branch.is_none()
3722            && self.journal.is_empty()
3723            && self.coverage_head.is_none()
3724            && self.pending_resyncs.is_empty()
3725            && self.health == CacheHealth::Healthy
3726            && self.safe_head.is_none()
3727            && self.finalized_head.is_none()
3728            && self.tracked_roots.is_empty()
3729            && self.last_gate_block.is_none()
3730            && self.touched_since_gate.is_empty()
3731            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
3732    }
3733
3734    fn adopted_baseline_only(&self) -> Option<BlockRef> {
3735        let baseline = self.coverage_head?;
3736        let journal_is_baseline_only = if self.config.journal_depth == 0 {
3737            self.journal.is_empty()
3738        } else {
3739            self.journal.len() == 1
3740                && self.journal.front().is_some_and(|entry| {
3741                    entry.block == baseline
3742                        && entry.inputs.is_empty()
3743                        && entry.applied.is_empty()
3744                        && entry.handler_ids.is_empty()
3745                        && entry.resynced.is_empty()
3746                        && entry.rollback_diffs.is_empty()
3747                })
3748        };
3749        (self.preconfirmed_branch.is_none()
3750            && journal_is_baseline_only
3751            && self.pending_resyncs.is_empty()
3752            && self.health == CacheHealth::Healthy
3753            && self.safe_head.is_none()
3754            && self.finalized_head.is_none()
3755            && self.tracked_roots.is_empty()
3756            && self.last_gate_block.is_none()
3757            && self.touched_since_gate.is_empty()
3758            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
3759        .then_some(baseline)
3760    }
3761
3762    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
3763        self.journal = state.journal;
3764        self.coverage_head = state.coverage_head;
3765        self.pending_resyncs = state.pending_resyncs;
3766        self.health = state.health;
3767        self.safe_head = state.safe_head;
3768        self.finalized_head = state.finalized_head;
3769        self.freshness = state.freshness;
3770        self.tracking = state.tracking;
3771        self.tracked_roots = state.tracked_roots;
3772        self.root_gate_cadence = state.root_gate_cadence;
3773        self.last_gate_block = state.last_gate_block;
3774        self.touched_since_gate = state.touched_since_gate;
3775        self.metrics.restore(state.metrics);
3776    }
3777
3778    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
3779        // Metrics describe lifetime observations, including rejected attempts,
3780        // and are documented as monotonic. Roll back canonical/runtime state
3781        // without erasing the failure signal that caused the transaction to
3782        // abort.
3783        let metrics = self.metrics.snapshot();
3784        self.restore_state(state);
3785        self.metrics.restore(metrics);
3786    }
3787
3788    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
3789        let checkpoint = DurableRuntimeCheckpoint {
3790            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
3791            safe_head: self.safe_head,
3792            finalized_head: self.finalized_head,
3793            health: self.health,
3794            pending_resyncs: self.pending_resyncs.clone(),
3795            coverage_head: self.coverage_head,
3796            journal: self
3797                .journal
3798                .iter()
3799                .map(|entry| DurableBlockJournal {
3800                    block: entry.block,
3801                    handler_ids: entry.handler_ids.clone(),
3802                    rollback_diffs: entry.rollback_diffs.clone(),
3803                })
3804                .collect(),
3805            freshness: self.freshness.clone(),
3806            tracking: self.tracking.clone(),
3807            tracked_roots: self.tracked_roots.clone(),
3808            root_gate_cadence: self.root_gate_cadence,
3809            last_gate_block: self.last_gate_block,
3810            touched_since_gate: self.touched_since_gate.clone(),
3811            metrics: self.metrics.snapshot(),
3812        };
3813        bincode::serialize(&checkpoint)
3814            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
3815    }
3816
3817    fn plan_durable_checkpoint_restore(
3818        &self,
3819        bytes: &[u8],
3820        expected_coverage: &BlockRef,
3821    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
3822        let mut cursor = std::io::Cursor::new(bytes);
3823        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
3824            .with_fixint_encoding()
3825            .with_limit(bytes.len() as u64)
3826            .deserialize_from(&mut cursor)
3827            .map_err(|error| {
3828                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
3829            })?;
3830        if cursor.position() != bytes.len() as u64 {
3831            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
3832                "runtime checkpoint has trailing bytes".to_owned(),
3833            ));
3834        }
3835        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
3836            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
3837                format!(
3838                    "unsupported runtime checkpoint version {}",
3839                    checkpoint.version
3840                ),
3841            ));
3842        }
3843        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
3844
3845        let retained = self.config.journal_depth.min(checkpoint.journal.len());
3846        let discard = checkpoint.journal.len() - retained;
3847        checkpoint.journal.drain(..discard);
3848        Ok(DurableRuntimeRestorePlan {
3849            checkpoint: Some(checkpoint),
3850            fallback_history: Vec::new(),
3851        })
3852    }
3853
3854    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
3855        let Some(checkpoint) = plan.checkpoint else {
3856            self.journal = plan
3857                .fallback_history
3858                .into_iter()
3859                .map(|block| BlockJournal {
3860                    block,
3861                    inputs: Vec::new(),
3862                    applied: Vec::new(),
3863                    handler_ids: Vec::new(),
3864                    resynced: Vec::new(),
3865                    rollback_diffs: Vec::new(),
3866                })
3867                .collect();
3868            return;
3869        };
3870        self.safe_head = checkpoint.safe_head;
3871        self.finalized_head = checkpoint.finalized_head;
3872        self.health = checkpoint.health;
3873        self.pending_resyncs = checkpoint.pending_resyncs;
3874        self.coverage_head = checkpoint.coverage_head;
3875        self.journal = checkpoint
3876            .journal
3877            .into_iter()
3878            .map(|entry| BlockJournal {
3879                block: entry.block,
3880                inputs: Vec::new(),
3881                applied: Vec::new(),
3882                handler_ids: entry.handler_ids,
3883                resynced: Vec::new(),
3884                rollback_diffs: entry.rollback_diffs,
3885            })
3886            .collect();
3887        self.freshness = checkpoint.freshness;
3888        self.tracking = checkpoint.tracking;
3889        self.tracked_roots = checkpoint.tracked_roots;
3890        self.root_gate_cadence = checkpoint.root_gate_cadence;
3891        self.last_gate_block = checkpoint.last_gate_block;
3892        self.touched_since_gate = checkpoint.touched_since_gate;
3893        self.metrics.restore(checkpoint.metrics);
3894    }
3895
3896    fn validate_durable_runtime_checkpoint(
3897        &self,
3898        checkpoint: &DurableRuntimeCheckpoint,
3899        expected_coverage: &BlockRef,
3900    ) -> Result<(), ReactiveCheckpointRestoreError> {
3901        let invalid =
3902            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
3903        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
3904            return Err(invalid(
3905                "runtime checkpoint is missing its canonical coverage head".into(),
3906            ));
3907        };
3908        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
3909            return Err(invalid(format!(
3910                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
3911                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
3912            )));
3913        }
3914        for (label, head) in [
3915            ("safe", checkpoint.safe_head.as_ref()),
3916            ("finalized", checkpoint.finalized_head.as_ref()),
3917        ] {
3918            let Some(head) = head else { continue };
3919            if head.number > coverage.number
3920                || (head.number == coverage.number && head.hash != coverage.hash)
3921            {
3922                return Err(invalid(format!(
3923                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
3924                    head.number, head.hash, coverage.number, coverage.hash
3925                )));
3926            }
3927            if head.number.checked_add(1) == Some(coverage.number)
3928                && coverage
3929                    .parent_hash
3930                    .is_some_and(|parent| parent != head.hash)
3931            {
3932                return Err(invalid(format!(
3933                    "canonical coverage does not descend from adjacent {label} head"
3934                )));
3935            }
3936        }
3937        if let (Some(finalized), Some(safe)) = (
3938            checkpoint.finalized_head.as_ref(),
3939            checkpoint.safe_head.as_ref(),
3940        ) {
3941            if finalized.number > safe.number
3942                || (finalized.number == safe.number && finalized.hash != safe.hash)
3943            {
3944                return Err(invalid(
3945                    "finalized head is above or conflicts with the safe head".into(),
3946                ));
3947            }
3948            if finalized.number.checked_add(1) == Some(safe.number)
3949                && safe.parent_hash != Some(finalized.hash)
3950            {
3951                return Err(invalid(
3952                    "adjacent safe head does not descend from finalized head".into(),
3953                ));
3954            }
3955        }
3956
3957        let mut previous: Option<&DurableBlockJournal> = None;
3958        for entry in &checkpoint.journal {
3959            if entry.block.number > coverage.number
3960                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
3961            {
3962                return Err(invalid(format!(
3963                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
3964                    entry.block.number, entry.block.hash
3965                )));
3966            }
3967            if let Some(previous) = previous {
3968                if entry.block.number <= previous.block.number {
3969                    return Err(invalid(
3970                        "runtime journal block numbers are not strictly increasing".into(),
3971                    ));
3972                }
3973                if previous.block.number.checked_add(1) == Some(entry.block.number)
3974                    && entry.block.parent_hash.is_some()
3975                    && entry.block.parent_hash != Some(previous.block.hash)
3976                {
3977                    return Err(invalid(
3978                        "adjacent runtime journal blocks are not parent-linked".into(),
3979                    ));
3980                }
3981            }
3982            for (label, head) in [
3983                ("safe", checkpoint.safe_head.as_ref()),
3984                ("finalized", checkpoint.finalized_head.as_ref()),
3985            ] {
3986                if let Some(head) = head
3987                    && head.number == entry.block.number
3988                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
3989                {
3990                    return Err(invalid(format!(
3991                        "{label} head conflicts with the retained journal at block {}",
3992                        head.number
3993                    )));
3994                }
3995            }
3996            let mut handler_ids = HashSet::new();
3997            if entry
3998                .handler_ids
3999                .iter()
4000                .any(|handler_id| !handler_ids.insert(handler_id))
4001            {
4002                return Err(invalid(
4003                    "runtime journal contains duplicate handler generation ids".into(),
4004                ));
4005            }
4006            previous = Some(entry);
4007        }
4008        if let Some(tail) = checkpoint.journal.last()
4009            && tail.block.number == coverage.number
4010            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4011        {
4012            return Err(invalid(format!(
4013                "runtime journal tail conflicts with canonical coverage at block {}",
4014                coverage.number
4015            )));
4016        }
4017        if let Some(tail) = checkpoint.journal.last()
4018            && tail.block.number.checked_add(1) == Some(coverage.number)
4019            && coverage
4020                .parent_hash
4021                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4022        {
4023            return Err(invalid(format!(
4024                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4025                tail.block.number
4026            )));
4027        }
4028
4029        if let Some(last_gate_block) = checkpoint.last_gate_block {
4030            if last_gate_block > coverage.number {
4031                return Err(invalid(
4032                    "root-gate cursor lies beyond canonical coverage".into(),
4033                ));
4034            }
4035        } else if !checkpoint.tracked_roots.is_empty() {
4036            return Err(invalid(
4037                "root-gate baselines exist without a completed gate cursor".into(),
4038            ));
4039        }
4040        for (address, baseline) in &checkpoint.tracked_roots {
4041            let Some(policy) = checkpoint.tracking.get(address) else {
4042                return Err(invalid(
4043                    "root-gate baseline has no corresponding tracking policy".into(),
4044                ));
4045            };
4046            if matches!(policy, TrackingPolicy::Slots { .. }) {
4047                return Err(invalid(
4048                    "slot-only tracking cannot carry an account root baseline".into(),
4049                ));
4050            }
4051            if baseline.last_block > coverage.number
4052                || checkpoint
4053                    .last_gate_block
4054                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4055            {
4056                return Err(invalid(
4057                    "root-gate baseline lies beyond the committed gate window".into(),
4058                ));
4059            }
4060        }
4061        Ok(())
4062    }
4063
4064    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4065    ///
4066    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4067    /// gate as a no-op. Registering an account clears any baseline it held (a
4068    /// policy change re-adopts on the next probe rather than diffing against a
4069    /// baseline captured under the old policy). Each [`RootGateCadence`]
4070    /// firing, the gate
4071    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4072    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4073    /// account-proof seam and, on a move no decoder covered, emits a
4074    /// [`ReactiveReport::CoverageGap`] and schedules a
4075    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4076    /// accounts are never root-gated (spec Decision 3).
4077    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4078        self.tracking.insert(address, policy);
4079        self.tracked_roots.remove(&address);
4080    }
4081
4082    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4083    ///
4084    /// Returns `true` if the account was tracked.
4085    pub fn untrack_account(&mut self, address: Address) -> bool {
4086        self.tracked_roots.remove(&address);
4087        self.tracking.remove(&address).is_some()
4088    }
4089
4090    /// Set how often the root gate probes tracked accounts (default:
4091    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4092    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4093    ///
4094    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4095    /// accumulator and the last-fired block), so a stale window never leaks
4096    /// into the new cadence: the next canonical block fires the gate.
4097    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4098        self.root_gate_cadence = cadence;
4099        self.last_gate_block = None;
4100        self.touched_since_gate.clear();
4101    }
4102
4103    /// The configured [`RootGateCadence`].
4104    pub fn root_gate_cadence(&self) -> RootGateCadence {
4105        self.root_gate_cadence
4106    }
4107
4108    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4109    ///
4110    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4111    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4112    /// touched `(address, slot)` as
4113    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4114    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4115    /// re-verify) but ages to volatile once the clock passes `N`.
4116    ///
4117    /// Idempotent: if a registry is already installed it is left untouched, so an
4118    /// existing registry (and any stamps it holds) is never clobbered.
4119    pub fn enable_freshness_stamping(&mut self) {
4120        if self.freshness.is_none() {
4121            self.freshness = Some(FreshnessRegistry::new());
4122        }
4123    }
4124
4125    /// Borrow the runtime's freshness registry, if stamping was enabled.
4126    ///
4127    /// Returns `None` unless
4128    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4129    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4130        self.freshness.as_ref()
4131    }
4132
4133    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4134    ///
4135    /// Returns `None` unless
4136    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4137    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4138        self.freshness.as_mut()
4139    }
4140
4141    /// Return the current queryable [`CacheHealth`] of the runtime.
4142    pub fn health(&self) -> CacheHealth {
4143        self.health
4144    }
4145
4146    /// Return a point-in-time snapshot of the runtime's observability counters.
4147    pub fn metrics(&self) -> CacheMetricsSnapshot {
4148        self.metrics.snapshot()
4149    }
4150
4151    /// Complete the caller-driven self-heal by returning health to
4152    /// [`CacheHealth::Healthy`].
4153    ///
4154    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4155    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4156    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4157    /// has resynced or rebuilt the affected state, it invokes this to clear the
4158    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4159    /// called outside an ingest cycle.
4160    pub fn reset_health(&mut self) {
4161        self.health = CacheHealth::Healthy;
4162    }
4163
4164    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4165    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4166    /// state actually changes.
4167    ///
4168    /// The ladder is:
4169    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4170    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4171    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4172    ///
4173    /// A first event degrades; a second escalates to the terminal
4174    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4175    /// trust-loss paths (deep reorg beyond the journal and missed-range
4176    /// detection) so mixed event types climb the same ladder.
4177    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4178        let to = match self.health {
4179            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4180            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4181            CacheHealth::Unhealthy { .. } => return None,
4182        };
4183        self.transition_health(to, Some(block))
4184    }
4185
4186    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4187    /// when the state actually changes.
4188    ///
4189    /// The returned report must be threaded into the ingest cycle's dispatched
4190    /// reports so it reaches hooks and appears in
4191    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4192    /// current state (no transition, no report).
4193    fn transition_health(
4194        &mut self,
4195        to: CacheHealth,
4196        block: Option<u64>,
4197    ) -> Option<Arc<ReactiveReport<N>>> {
4198        if to == self.health {
4199            return None;
4200        }
4201        let from = self.health;
4202        self.health = to;
4203        Some(Arc::new(ReactiveReport::Health(HealthReport {
4204            from,
4205            to,
4206            block,
4207            _network: PhantomData,
4208        })))
4209    }
4210
4211    /// Register a handler.
4212    ///
4213    /// # Errors
4214    ///
4215    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4216    /// registered.
4217    pub fn register_handler(
4218        &mut self,
4219        handler: Arc<dyn ReactiveHandler<N>>,
4220    ) -> Result<(), RegisterError> {
4221        self.registry.register_handler(handler)
4222    }
4223
4224    /// Remove one handler from the runtime registry without resetting runtime state.
4225    ///
4226    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4227    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4228    /// tracking policy, freshness registry, or root-gate baselines, and it does
4229    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4230    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4231    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4232        self.registry.unregister_handler(id)
4233    }
4234
4235    /// Return true when the runtime has a registered handler with `id`.
4236    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4237        self.registry.contains_handler(id)
4238    }
4239
4240    /// Ids of all registered handlers, in registration (= routing) order.
4241    pub fn handler_ids(&self) -> Vec<HandlerId> {
4242        self.registry.handler_ids()
4243    }
4244
4245    /// Borrow the interests owned by one registered handler.
4246    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4247        self.registry.handler_interests(id)
4248    }
4249
4250    /// The most recently journaled canonical block, if any.
4251    ///
4252    /// This is the runtime's current chain position: the canonical block most
4253    /// recently recorded by ingestion. Reorged blocks are dropped from the
4254    /// journal during recovery, so a rolled-back head does not linger here.
4255    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4256    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4257    /// advance this coverage position across an empty event range. `None` until
4258    /// the first canonical input or barrier is accepted.
4259    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4260        self.coverage_head
4261    }
4262
4263    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4264    /// position without applying effects or dispatching reports.
4265    ///
4266    /// Handlers, hooks, tracking policy, and freshness configuration may be
4267    /// installed before adoption, but no chain input, finality, resync,
4268    /// root-gate observation, or health transition may have occurred. An exact
4269    /// repeat is idempotent; a different repeat and any active runtime fail
4270    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4271    /// and subscriber are available so chain identity and the cache's exact
4272    /// hash pin are validated too.
4273    ///
4274    /// # Errors
4275    ///
4276    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4277    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4278    /// different baseline has already been adopted.
4279    pub fn adopt_canonical_baseline(
4280        &mut self,
4281        baseline: BlockRef,
4282    ) -> Result<(), ReactiveBaselineError> {
4283        self.validate_canonical_baseline_adoption(baseline)?;
4284        if self.adopted_baseline_only().is_some() {
4285            return Ok(());
4286        }
4287
4288        self.coverage_head = Some(baseline);
4289        if self.config.journal_depth > 0 {
4290            self.journal.push_back(BlockJournal {
4291                block: baseline,
4292                inputs: Vec::new(),
4293                applied: Vec::new(),
4294                handler_ids: Vec::new(),
4295                resynced: Vec::new(),
4296                rollback_diffs: Vec::new(),
4297            });
4298        }
4299        Ok(())
4300    }
4301
4302    fn validate_canonical_baseline_adoption(
4303        &self,
4304        baseline: BlockRef,
4305    ) -> Result<(), ReactiveBaselineError> {
4306        if let Some(existing) = self.adopted_baseline_only() {
4307            return if existing == baseline {
4308                Ok(())
4309            } else {
4310                Err(ReactiveBaselineError::ConflictingBaseline {
4311                    existing_number: existing.number,
4312                    existing_hash: existing.hash,
4313                    requested_number: baseline.number,
4314                    requested_hash: baseline.hash,
4315                })
4316            };
4317        }
4318        if !self.is_pristine_for_checkpoint_restore() {
4319            return Err(ReactiveBaselineError::ActiveRuntime);
4320        }
4321        Ok(())
4322    }
4323
4324    /// Most recent safe head explicitly reported by the event source.
4325    pub const fn safe_head(&self) -> Option<&BlockRef> {
4326        self.safe_head.as_ref()
4327    }
4328
4329    /// Most recent finalized head explicitly reported by the event source.
4330    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4331        self.finalized_head.as_ref()
4332    }
4333
4334    /// Return whether the retained reorg journal still contains an applied
4335    /// record for `handler_id`.
4336    ///
4337    /// The record is retained even when the handler emitted only resync work,
4338    /// so an owner can keep an explicit cache-eviction fence active for exactly
4339    /// as long as a later rollback could restore effects from that handler
4340    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4341    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4342        self.journal
4343            .iter()
4344            .any(|entry| entry.handler_ids.contains(handler_id))
4345    }
4346
4347    /// Return the distinct handler generations represented in the retained
4348    /// reorg journal.
4349    ///
4350    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4351    /// large set of cache-eviction fences without rescanning the journal for
4352    /// every handler.
4353    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4354        self.journal
4355            .iter()
4356            .flat_map(|entry| entry.handler_ids.iter().cloned())
4357            .collect()
4358    }
4359
4360    /// Queued resync requests: surfaced by handlers but not yet executed by an
4361    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4362    ///
4363    /// Callers driving resync execution themselves (plain
4364    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4365    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4366    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4367    /// generation-owned work, while
4368    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4369    /// for exclusively torn-down accounts.
4370    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4371        &self.pending_resyncs
4372    }
4373
4374    /// Cancel every queued request with the exact logical `id`.
4375    ///
4376    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4377    /// removes whole requests and never touches other work merely because it
4378    /// targets the same account. It is therefore the safe primitive for
4379    /// generation-scoped owner teardown when the caller maintains an
4380    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4381    /// an earlier batch report cannot be recalled.
4382    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4383        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4384    }
4385
4386    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4387    ///
4388    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4389    /// pending-queue order, independent of caller id order. This is the batch
4390    /// teardown primitive for owners that can have many pending repairs; it
4391    /// avoids rescanning the complete pending queue once per owned id.
4392    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4393        if ids.is_empty() {
4394            return Vec::new();
4395        }
4396        let ids: HashSet<&ResyncId> = ids.iter().collect();
4397        let mut cancelled = Vec::new();
4398        self.pending_resyncs.retain(|request| {
4399            if ids.contains(&request.id) {
4400                cancelled.push(request.clone());
4401                false
4402            } else {
4403                true
4404            }
4405        });
4406        cancelled
4407    }
4408
4409    /// Cancel queued resync work that targets `address`, returning the
4410    /// cancelled portions.
4411    ///
4412    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4413    /// a request reduced to zero targets is dropped entirely, while
4414    /// mixed-target requests keep their other accounts queued. Each returned
4415    /// request mirrors the original id/reason/block/priority and carries only
4416    /// the targets that were cancelled.
4417    ///
4418    /// This is appropriate only when the caller owns the complete account. For
4419    /// a pool sharing a vault or emitter with other owners, cancel its exact
4420    /// request IDs through
4421    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4422    /// recall requests already returned to the caller in earlier batch reports.
4423    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4424        let mut cancelled = Vec::new();
4425        self.pending_resyncs.retain_mut(|request| {
4426            let (matching, remaining): (Vec<_>, Vec<_>) = request
4427                .targets
4428                .drain(..)
4429                .partition(|target| resync_target_address(target) == address);
4430            request.targets = remaining;
4431            if !matching.is_empty() {
4432                cancelled.push(ResyncRequest {
4433                    id: request.id.clone(),
4434                    reason: request.reason.clone(),
4435                    block: request.block.clone(),
4436                    targets: matching,
4437                    priority: request.priority,
4438                });
4439            }
4440            !request.targets.is_empty()
4441        });
4442        cancelled
4443    }
4444
4445    /// Register a hook.
4446    ///
4447    /// # Errors
4448    ///
4449    /// This implementation is currently infallible; the `Result` preserves the
4450    /// registration contract for future hook validation.
4451    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4452        self.hooks.push(hook);
4453        Ok(())
4454    }
4455
4456    /// Return all registered interests in handler registration order.
4457    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4458        self.registry.interests()
4459    }
4460
4461    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4462    ///
4463    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4464    /// restored before the error returns, and hooks see no reports. Monotonic
4465    /// observability counters still retain rejected-attempt signals.
4466    /// The current rollback guard snapshots complete mutable cache state once per
4467    /// batch, so callers should preserve transport batching rather than splitting
4468    /// one delivery into many one-record calls.
4469    ///
4470    /// # Errors
4471    ///
4472    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4473    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4474    /// be applied. Cache and canonical runtime state are restored before return.
4475    pub fn ingest_batch(
4476        &mut self,
4477        cache: &mut EvmCache,
4478        batch: ReactiveInputBatch<N>,
4479    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4480        let preconfirmation = batch_preconfirmation(&batch)?;
4481        if let Some(flashblock) = preconfirmation.as_ref() {
4482            self.prepare_preconfirmed_branch(cache, flashblock)?;
4483        } else {
4484            self.discard_preconfirmed_branch(cache);
4485        }
4486        let cache_state = EvmCacheStateSnapshot::capture(cache);
4487        let runtime_state = self.checkpoint_state();
4488        let batch_report = match self.ingest_batch_direct(cache, batch) {
4489            Ok(report) => report,
4490            Err(error) => {
4491                cache_state.restore(cache);
4492                self.restore_transaction_state(runtime_state);
4493                return Err(error);
4494            }
4495        };
4496        if let Some(flashblock) = preconfirmation {
4497            self.restore_transaction_state(runtime_state);
4498            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4499                branch.flashblock = flashblock;
4500            }
4501        }
4502        self.dispatch_reports(&batch_report.reports);
4503        let _ = &self.config;
4504        Ok(batch_report)
4505    }
4506
4507    /// Ingest a batch, then execute surfaced storage resync requests.
4508    ///
4509    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
4510    /// direct handler effects, then runs a synchronous resync phase over the
4511    /// collected [`ResyncRequest`]s. Storage targets are fetched through
4512    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
4513    /// values are applied as [`StateUpdate::slot`] updates through
4514    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
4515    /// in [`ResyncReport::failed`]. It does not start subscribers, background
4516    /// workers, or network transport.
4517    ///
4518    /// # Errors
4519    ///
4520    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
4521    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
4522    /// resync targets are reported in the successful batch report instead.
4523    pub fn ingest_batch_with_resync(
4524        &mut self,
4525        cache: &mut EvmCache,
4526        batch: ReactiveInputBatch<N>,
4527    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4528        let preconfirmation = batch_preconfirmation(&batch)?;
4529        if let Some(flashblock) = preconfirmation.as_ref() {
4530            self.prepare_preconfirmed_branch(cache, flashblock)?;
4531        } else {
4532            self.discard_preconfirmed_branch(cache);
4533        }
4534        let cache_state = EvmCacheStateSnapshot::capture(cache);
4535        let runtime_state = self.checkpoint_state();
4536        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
4537            Ok(report) => report,
4538            Err(error) => {
4539                cache_state.restore(cache);
4540                self.restore_transaction_state(runtime_state);
4541                return Err(error);
4542            }
4543        };
4544
4545        if let Some(flashblock) = preconfirmation {
4546            self.restore_transaction_state(runtime_state);
4547            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4548                branch.flashblock = flashblock;
4549            }
4550        }
4551
4552        self.dispatch_reports(&batch_report.reports);
4553        let _ = &self.config;
4554        Ok(batch_report)
4555    }
4556
4557    /// Active speculative Flashblock snapshot, when the cache currently
4558    /// includes pre-confirmed effects.
4559    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
4560        self.preconfirmed_branch
4561            .as_ref()
4562            .map(|branch| &branch.flashblock)
4563    }
4564
4565    /// Restore the cache to its canonical state and discard any speculative
4566    /// Flashblock effects.
4567    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
4568        self.discard_preconfirmed_branch(cache);
4569    }
4570
4571    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
4572        if let Some(branch) = self.preconfirmed_branch.take() {
4573            branch.canonical_cache.restore(cache);
4574        }
4575    }
4576
4577    fn prepare_preconfirmed_branch(
4578        &mut self,
4579        cache: &mut EvmCache,
4580        incoming: &FlashblockRef,
4581    ) -> Result<(), ReactiveError> {
4582        if let Some(active) = self.preconfirmed_branch.as_ref()
4583            && active.flashblock.same_payload(incoming)
4584        {
4585            if let (Some(active_index), Some(incoming_index)) =
4586                (active.flashblock.index, incoming.index)
4587                && incoming_index < active_index
4588            {
4589                return Err(ReactiveError::InvalidInputRecord {
4590                    message: format!(
4591                        "Flashblock index regressed from {active_index} to {incoming_index}"
4592                    ),
4593                });
4594            }
4595            if active.flashblock.index == incoming.index
4596                && active.flashblock.block_hash != incoming.block_hash
4597            {
4598                return Err(ReactiveError::InvalidInputRecord {
4599                    message:
4600                        "same Flashblock payload/index carried conflicting partial block hashes"
4601                            .into(),
4602                });
4603            }
4604            return Ok(());
4605        }
4606
4607        self.discard_preconfirmed_branch(cache);
4608        self.preconfirmed_branch = Some(PreconfirmedBranch {
4609            flashblock: incoming.clone(),
4610            canonical_cache: EvmCacheStateSnapshot::capture(cache),
4611        });
4612        Ok(())
4613    }
4614
4615    fn ingest_batch_with_resync_direct(
4616        &mut self,
4617        cache: &mut EvmCache,
4618        batch: ReactiveInputBatch<N>,
4619    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4620        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
4621        if !batch_report.resyncs.is_empty() {
4622            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
4623            // Count unique logical requests: several handlers may emit the same
4624            // ResyncId in one batch, and duplicates fan out per-origin in the
4625            // report but are one unit of resync work for the metric.
4626            let unique_requests = resync_report
4627                .requested
4628                .iter()
4629                .map(|request| &request.id)
4630                .collect::<HashSet<_>>()
4631                .len();
4632            self.metrics
4633                .resync_requests
4634                .fetch_add(unique_requests as u64, Ordering::Relaxed);
4635            self.metrics
4636                .resync_failures
4637                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
4638            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
4639            self.record_journal_resync(&resync_report);
4640            batch_report
4641                .reports
4642                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
4643        }
4644        Ok(batch_report)
4645    }
4646
4647    fn ingest_batch_direct(
4648        &mut self,
4649        cache: &mut EvmCache,
4650        batch: ReactiveInputBatch<N>,
4651    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4652        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
4653        if let Some(chain_id) = batch_chain_id
4654            && chain_id != cache.chain_id()
4655        {
4656            return Err(ReactiveError::InvalidInputRecord {
4657                message: format!(
4658                    "batch chain id {chain_id} does not match cache chain id {}",
4659                    cache.chain_id()
4660                ),
4661            });
4662        }
4663        if !chain_controls.is_empty() && batch_chain_id.is_none() {
4664            return Err(ReactiveError::InvalidChainControl {
4665                message: "chain-control batches require an authoritative batch chain id".into(),
4666            });
4667        }
4668        for (record, _, _) in &records {
4669            record.validated_identity()?;
4670            if let Some(chain_id) = record.context.chain_id
4671                && chain_id != cache.chain_id()
4672            {
4673                return Err(ReactiveError::InvalidInputRecord {
4674                    message: format!(
4675                        "input chain id {chain_id} does not match cache chain id {}",
4676                        cache.chain_id()
4677                    ),
4678                });
4679            }
4680        }
4681        let records = sort_scoped_records(dedupe_scoped_records(records)?);
4682
4683        let mut batch_report = ReactiveBatchReport::default();
4684        let mut reports_to_dispatch = Vec::new();
4685        let control_split = validate_control_phase_order(&chain_controls)?;
4686        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
4687        let pre_record_state =
4688            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
4689        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
4690        let mut batch_dropped = BatchDroppedCanonical::default();
4691        for control in pre_record_controls {
4692            if let ChainControl::Reorg {
4693                common_ancestor,
4694                old_tip,
4695                ..
4696            } = control
4697            {
4698                batch_dropped.record_explicit(common_ancestor, old_tip);
4699                let drained = self
4700                    .journal
4701                    .iter()
4702                    .filter(|entry| entry.block.number > common_ancestor.number)
4703                    .map(|entry| entry.block)
4704                    .collect::<Vec<_>>();
4705                batch_dropped.record_drained(&drained);
4706            }
4707        }
4708        let certified_progress_through = post_record_controls
4709            .iter()
4710            .filter_map(canonical_coverage_control_block)
4711            .map(|block| block.number)
4712            .max();
4713        for control in pre_record_controls.iter().cloned() {
4714            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
4715        }
4716        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
4717        // batch (union of applied `StateDiff` addresses) and the batch's canonical
4718        // block number, so the per-block root gate can run once after the record
4719        // loop with the full touched set.
4720        let mut touched_addrs: HashSet<Address> = HashSet::new();
4721        let mut canonical_batch_block: Option<u64> = None;
4722
4723        for (record, audience, delivery_scope) in records {
4724            let raw_canonical_block = canonical_record_block(&record).copied();
4725            let canonical_block = raw_canonical_block.map(|block| {
4726                pre_record_state
4727                    .resolved_canonical_blocks
4728                    .get(&(block.number, block.hash))
4729                    .copied()
4730                    .unwrap_or(block)
4731            });
4732            let input_ref = record.input_ref();
4733            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
4734                input_ref,
4735                context: record.context.clone(),
4736                provider: record.provider.clone(),
4737                _network: PhantomData,
4738            })));
4739
4740            let recovered_reorg = if delivery_scope.advances_canonical_state() {
4741                if let Some(block) = canonical_block.as_ref() {
4742                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
4743                        && certified_progress_through
4744                            .is_some_and(|through| block.number <= through);
4745                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
4746                        raw.parent_hash.is_none()
4747                            && batch_dropped.covers_implicit_number(raw.number)
4748                    });
4749                    self.recover_for_canonical_input(
4750                        cache,
4751                        block,
4752                        gap_is_certified,
4753                        parentless_replacement_is_proven,
4754                        &mut reports_to_dispatch,
4755                    )
4756                } else {
4757                    None
4758                }
4759            } else {
4760                None
4761            };
4762            let recovered_reorg_for_input = recovered_reorg.is_some();
4763            if let Some(reorg_report) = recovered_reorg {
4764                self.metrics
4765                    .reorgs_recovered
4766                    .fetch_add(1, Ordering::Relaxed);
4767                remove_canceled_resyncs_from_batch(
4768                    &mut batch_report.resyncs,
4769                    &reorg_report.canceled_resyncs,
4770                );
4771                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
4772            }
4773
4774            // Removed/reorged records are lifecycle signals, never handler
4775            // data. Canonical scopes may roll back state; owner-only catch-up
4776            // scopes deliberately cannot, but both must suppress ordinary
4777            // decoding even when the referenced block is unknown, aged out of
4778            // the journal, or has already been removed once.
4779            if reorg_signal_block(&record).is_some() {
4780                if delivery_scope.advances_canonical_state()
4781                    && let Some(reorg_report) = self.recover_for_reorged_input(
4782                        cache,
4783                        &record,
4784                        &mut batch_dropped,
4785                        &mut reports_to_dispatch,
4786                    )
4787                {
4788                    self.metrics
4789                        .reorgs_recovered
4790                        .fetch_add(1, Ordering::Relaxed);
4791                    remove_canceled_resyncs_from_batch(
4792                        &mut batch_report.resyncs,
4793                        &reorg_report.canceled_resyncs,
4794                    );
4795                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
4796                }
4797                continue;
4798            }
4799
4800            // Preflight validates owner history against the journal state at
4801            // batch entry. A canonical record earlier in this same transaction
4802            // may legitimately replace and drain that block, so close the
4803            // resulting TOCTOU window immediately before any owner handler can
4804            // mutate the cache. The outer transaction guard restores every
4805            // earlier record in the batch on failure.
4806            if delivery_scope == DeliveryScope::OwnerCatchup {
4807                self.validate_owner_catchup_record_against_current_journal(&record)?;
4808            }
4809
4810            if delivery_scope.advances_canonical_state()
4811                && let Some(block) = canonical_block.as_ref()
4812            {
4813                // Phase-8 step 4: remember the batch's canonical block (the last
4814                // canonical record wins) so the root gate probes at that height.
4815                canonical_batch_block = Some(block.number);
4816                self.record_journal_input(block, input_ref);
4817            }
4818
4819            // Keep every lazy provider read pinned to the exact event block
4820            // before handlers run. A full header installs the complete EVM env;
4821            // compact log-only progress installs NUMBER/timestamp and clears
4822            // unknown header-only fields. A later record for the same retained
4823            // canonical block can preserve an already-installed full env.
4824            if delivery_scope.advances_canonical_state()
4825                && let Some(block) = canonical_block.as_ref()
4826            {
4827                match advance_block_for_canonical_record(cache, &record) {
4828                    Some(Ok(())) => {
4829                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
4830                    }
4831                    Some(Err(err)) => {
4832                        cache.advance_compact_block(
4833                            block.number,
4834                            block.hash,
4835                            block.timestamp,
4836                            false,
4837                        );
4838                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
4839                            ReactiveErrorReport {
4840                                input_ref: Some(input_ref),
4841                                message: err.to_string(),
4842                                _network: PhantomData,
4843                            },
4844                        )));
4845                    }
4846                    None => cache.advance_compact_block(
4847                        block.number,
4848                        block.hash,
4849                        block.timestamp,
4850                        !recovered_reorg_for_input,
4851                    ),
4852                }
4853            }
4854
4855            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
4856            if executions.is_empty() {
4857                continue;
4858            }
4859
4860            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
4861                input_ref,
4862                handler_ids: executions
4863                    .iter()
4864                    .map(|execution| execution.handler_id.clone())
4865                    .collect(),
4866                _network: PhantomData,
4867            })));
4868
4869            detect_conflicts(input_ref, &executions)?;
4870
4871            // Phase-8 step 3: canonical block number for freshness stamping.
4872            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
4873            // can be used while `self.freshness_mut()` mutably borrows `self`
4874            // inside the execution loop. `None` for pending/removed/reorged
4875            // records — those never stamp canonical freshness.
4876            let canonical_block_number = delivery_scope
4877                .advances_canonical_state()
4878                .then_some(canonical_block)
4879                .flatten()
4880                .map(|block| block.number);
4881
4882            for execution in executions {
4883                let diff = if execution.state_updates.is_empty() {
4884                    StateDiff::default()
4885                } else {
4886                    cache.apply_updates(&execution.state_updates)
4887                };
4888
4889                batch_report
4890                    .resyncs
4891                    .extend(execution.resyncs.iter().cloned());
4892                self.pending_resyncs
4893                    .extend(execution.resyncs.iter().cloned());
4894                batch_report
4895                    .speculative
4896                    .extend(execution.speculative.iter().cloned());
4897
4898                let applied = AppliedReport {
4899                    input_ref,
4900                    handler_id: execution.handler_id,
4901                    quality: execution.quality,
4902                    tags: execution.tags,
4903                    diff,
4904                    state_updates: execution.state_updates,
4905                    invalidations: execution.invalidations,
4906                    resyncs: execution.resyncs,
4907                    speculative: execution.speculative,
4908                    hook_signals: execution.hook_signals,
4909                    _network: PhantomData,
4910                };
4911                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
4912                // from this canonical handler write as `ValidThrough(N)`, so an
4913                // event-maintained slot stops being re-verified until the clock
4914                // passes its write block. Read the changed slots straight off
4915                // `applied.diff` (which borrows the local, not `self`) and stamp
4916                // via `self.freshness`, done before `applied` is moved into the
4917                // journal/batch below. Only genuinely-changed slots appear here,
4918                // since a no-op re-write records no `SlotChange`.
4919                if let (Some(number), Some(registry)) =
4920                    (canonical_block_number, self.freshness.as_mut())
4921                {
4922                    for change in &applied.diff.slots {
4923                        registry.valid_through_slot(change.address, change.slot, number);
4924                    }
4925                }
4926
4927                // Phase-8 step 4: record every address this decoder actually wrote
4928                // (or attempted to write) so the root gate can tell a
4929                // decoder-covered root move from an uncovered coverage gap. Fold in
4930                // the full `StateDiff` address footprint — real changes
4931                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
4932                // a decoder that tried to write a cold slot still counts as
4933                // covering the account.
4934                if delivery_scope.advances_canonical_state() {
4935                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
4936                }
4937
4938                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
4939                reports_to_dispatch.push(report);
4940                if let Some(block) = canonical_block.as_ref() {
4941                    if delivery_scope.advances_canonical_state() {
4942                        self.record_journal_applied(block, applied.clone());
4943                    } else {
4944                        self.record_journal_applied_if_present(block, applied.clone());
4945                    }
4946                }
4947                batch_report.applied.push(applied);
4948            }
4949        }
4950
4951        // Coverage/finality controls certify the records that precede them.
4952        // Applying them here also leaves the live cache pinned to a certified
4953        // zero-event tail rather than the last block that happened to emit a
4954        // matching log. Reorg controls were applied before the record loop.
4955        for control in post_record_controls.iter().cloned() {
4956            if let Some(block) = canonical_coverage_control_block(&control) {
4957                canonical_batch_block = Some(
4958                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
4959                );
4960            }
4961            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
4962        }
4963
4964        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
4965        // addresses (after all handler effects, so the set is complete), then
4966        // fire the root gate only on cadence boundaries. The gate diffs
4967        // against persisted baselines, so skipped blocks lose no detection —
4968        // but the touched set must be the union since the last firing, or a
4969        // decoder-covered write in a skipped block would false-positive as a
4970        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
4971        // callers see them and `ingest_batch_with_resync` executes them) and
4972        // coverage reports go into the dispatched reports.
4973        if self.root_gate_runnable(cache) {
4974            self.touched_since_gate
4975                .extend(touched_addrs.iter().copied());
4976            if self.root_gate_due(canonical_batch_block) {
4977                let accumulated = std::mem::take(&mut self.touched_since_gate);
4978                self.run_root_gate(
4979                    cache,
4980                    canonical_batch_block,
4981                    &accumulated,
4982                    &mut batch_report.resyncs,
4983                    &mut reports_to_dispatch,
4984                );
4985                self.last_gate_block = canonical_batch_block;
4986            }
4987        } else {
4988            // A gate that cannot run (disabled, nothing root-gated, or no
4989            // proof fetcher) must not grow the accumulator unboundedly.
4990            // Dropping it is safe: without a runnable gate no baselines exist
4991            // (a fetcher cannot be uninstalled, and untracking drops the
4992            // baseline), so there is nothing a lost touched set could falsely
4993            // gap against later.
4994            self.touched_since_gate.clear();
4995        }
4996
4997        batch_report.reports = reports_to_dispatch;
4998        Ok(batch_report)
4999    }
5000
5001    /// Prove that every owner-only historical effect can be attached to an
5002    /// compatible retained canonical journal entry before any chain control or
5003    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5004    /// optional enrichment, but two present values must agree; this matches the
5005    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5006    ///
5007    /// Owner catch-up deliberately does not advance canonical coverage. Its
5008    /// effects are appended to the already-existing journal entry so a later
5009    /// reorg can roll them back with the rest of that block. Accepting a block
5010    /// outside the journal would make the cache mutation irreversible. A reorg
5011    /// control in the same batch also invalidates entries above its ancestor,
5012    /// so those entries are rejected even though they still exist at this
5013    /// preflight point.
5014    fn validate_owner_catchup_against_journal(
5015        &self,
5016        control_state: &ChainControlState,
5017        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5018    ) -> Result<(), ReactiveError> {
5019        for (record, _, delivery_scope) in records {
5020            if *delivery_scope != DeliveryScope::OwnerCatchup {
5021                continue;
5022            }
5023            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5024            // cannot make them canonical and the record loop deliberately skips
5025            // handler execution, so there is no effect that needs attaching to
5026            // a rollback journal entry.
5027            if reorg_signal_block(record).is_some() {
5028                continue;
5029            }
5030            let context_block = canonical_record_block(record).ok_or_else(|| {
5031                ReactiveError::InvalidChainControl {
5032                    message: "owner catch-up input has no canonical block identity".into(),
5033                }
5034            })?;
5035            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5036            let invalidated_by_control = control_state
5037                .journal_invalidated_from
5038                .is_some_and(|from| block.number >= from);
5039            let rollbackable = !invalidated_by_control
5040                && self.journal.iter().any(|entry| {
5041                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5042                });
5043            if !rollbackable {
5044                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5045                    number: block.number,
5046                    hash: block.hash,
5047                });
5048            }
5049        }
5050        Ok(())
5051    }
5052
5053    fn validate_owner_catchup_record_against_current_journal(
5054        &self,
5055        record: &ReactiveInputRecord<N>,
5056    ) -> Result<(), ReactiveError> {
5057        let context_block =
5058            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5059                message: "owner catch-up input has no canonical block identity".into(),
5060            })?;
5061        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5062        if self
5063            .journal
5064            .iter()
5065            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5066        {
5067            return Ok(());
5068        }
5069        Err(ReactiveError::OwnerCatchupOutsideJournal {
5070            number: block.number,
5071            hash: block.hash,
5072        })
5073    }
5074
5075    /// Whether the root gate could produce any signal at all: some tracked
5076    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5077    /// When this is false the touched accumulator is dropped rather than
5078    /// grown (see the ingest call site for why that is safe).
5079    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5080        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5081            return false;
5082        }
5083        let has_gated_targets = self
5084            .tracking
5085            .values()
5086            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5087        has_gated_targets && cache.account_proof_fetcher().is_some()
5088    }
5089
5090    /// Whether the root gate is due at this batch's canonical block (§6.2):
5091    /// the first canonical block ever seen always fires (baseline adoption
5092    /// must not wait a full window), then at most once every `n` blocks.
5093    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5094        let Some(block) = canonical_block else {
5095            return false;
5096        };
5097        match self.root_gate_cadence {
5098            RootGateCadence::Disabled => false,
5099            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5100                None => true,
5101                Some(last) => block >= last.saturating_add(n.get()),
5102            },
5103        }
5104    }
5105
5106    /// The `storageHash` root gate (Phase-8 step 4), fired per
5107    /// [`RootGateCadence`] window (§6.2).
5108    ///
5109    /// Runs at the firing batch's canonical block, with `touched` carrying the
5110    /// union of decoder-touched addresses since the previous firing. For each tracked
5111    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5112    /// account, probe the root (and account fields) via the account-proof seam and
5113    /// apply the spec §4 table:
5114    ///
5115    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5116    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5117    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5118    ///   ⇒ a decoder covered it; re-adopt, no gap.
5119    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5120    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5121    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5122    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5123    ///   the baseline (native field changes never move the storage root); on a move
5124    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5125    ///   resync for the changed fields and re-adopt.
5126    ///
5127    /// No-op when the tracking registry is empty, when the batch has no canonical
5128    /// block, or when the cache has no account-proof fetcher installed.
5129    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5130    /// Decision 3).
5131    fn run_root_gate(
5132        &mut self,
5133        cache: &EvmCache,
5134        canonical_block: Option<u64>,
5135        touched: &HashSet<Address>,
5136        resyncs: &mut Vec<ResyncRequest>,
5137        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5138    ) {
5139        if self.tracking.is_empty() {
5140            return;
5141        }
5142        let Some(block) = canonical_block else {
5143            return;
5144        };
5145        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5146            return;
5147        };
5148
5149        // Collect the root-gated targets (Slots opts out) in a stable order so a
5150        // single-block sequence of resyncs/reports is deterministic.
5151        let mut targets: Vec<(Address, bool)> = self
5152            .tracking
5153            .iter()
5154            .filter_map(|(address, policy)| match policy {
5155                TrackingPolicy::Slots { .. } => None,
5156                TrackingPolicy::WholeAccount => Some((*address, true)),
5157                TrackingPolicy::Scalars => Some((*address, false)),
5158            })
5159            .collect();
5160        if targets.is_empty() {
5161            return;
5162        }
5163        targets.sort_by_key(|(address, _)| *address);
5164
5165        let block_id = BlockId::number(block);
5166        // ONE seam invocation carries every root-gated target (root-only
5167        // probes: no storage keys needed). eth_getProof is single-address at
5168        // the RPC level, so batching here lets the fetcher fan the requests
5169        // out concurrently instead of paying N sequential round trips.
5170        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5171            targets
5172                .iter()
5173                .map(|&(address, _)| (address, vec![]))
5174                .collect(),
5175            block_id,
5176        )
5177        .into_iter()
5178        .collect();
5179        for (address, whole_account) in targets {
5180            let Some(Ok(proof)) = probes.remove(&address) else {
5181                // A failed/omitted probe carries no signal; leave the baseline
5182                // untouched and try again next block.
5183                continue;
5184            };
5185
5186            let baseline = self.tracked_roots.get(&address).cloned();
5187            let Some(baseline) = baseline else {
5188                // First observation: adopt the baseline. Not a coverage gap.
5189                self.adopt_root(address, block, &proof);
5190                continue;
5191            };
5192
5193            // A stale probe (a batch whose canonical block is not newer than the
5194            // last one we baselined this account against) carries no forward
5195            // signal: skip it rather than diff against — or clobber — a newer
5196            // baseline.
5197            if block <= baseline.last_block {
5198                continue;
5199            }
5200
5201            if whole_account {
5202                if proof.storage_hash == baseline.last_root {
5203                    // Tight steady-state path: unchanged root ⇒ nothing.
5204                    continue;
5205                }
5206                // Root moved.
5207                if !touched.contains(&address) {
5208                    // Moved with no covering decoder — the coverage gap.
5209                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5210                        address,
5211                        block,
5212                        _network: PhantomData,
5213                    })));
5214                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5215                    resyncs.push(root_moved_account_resync(
5216                        address,
5217                        block,
5218                        AccountFieldMask {
5219                            balance: true,
5220                            nonce: true,
5221                            code: true,
5222                        },
5223                    ));
5224                }
5225                // Adopt the new root whether or not a decoder covered it.
5226                self.adopt_root(address, block, &proof);
5227            } else {
5228                // Scalars: compare the account fields directly (native changes do
5229                // not move the storage root).
5230                let balance_moved = proof.balance != baseline.balance;
5231                let nonce_moved = proof.nonce != baseline.nonce;
5232                let code_moved = proof.code_hash != baseline.code_hash;
5233                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5234                    resyncs.push(root_moved_account_resync(
5235                        address,
5236                        block,
5237                        AccountFieldMask {
5238                            balance: balance_moved,
5239                            nonce: nonce_moved,
5240                            code: code_moved,
5241                        },
5242                    ));
5243                }
5244                self.adopt_root(address, block, &proof);
5245            }
5246        }
5247    }
5248
5249    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5250    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5251        self.tracked_roots.insert(
5252            address,
5253            TrackedRoot {
5254                last_root: proof.storage_hash,
5255                last_block: block,
5256                balance: proof.balance,
5257                nonce: proof.nonce,
5258                code_hash: proof.code_hash,
5259            },
5260        );
5261    }
5262
5263    fn execute_handlers(
5264        &self,
5265        cache: &EvmCache,
5266        record: &ReactiveInputRecord<N>,
5267        input_ref: InputRef,
5268        audience: &DeliveryAudience,
5269    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5270        let mut executions = Vec::new();
5271        let candidates: Vec<_> = match &record.input {
5272            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5273            ReactiveInput::BlockHeader(_)
5274            | ReactiveInput::FullBlock(_)
5275            | ReactiveInput::PendingTxHash(_)
5276            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5277        };
5278        for registered in candidates {
5279            match audience {
5280                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5281                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5282                    continue;
5283                }
5284                DeliveryAudience::All
5285                | DeliveryAudience::Owners(_)
5286                | DeliveryAudience::AllExcept(_) => {}
5287            }
5288            if !registered.matches(&record.input) {
5289                continue;
5290            }
5291
5292            let outcome = registered
5293                .handler
5294                .handle(&record.context, &record.input, cache)
5295                .map_err(|source| ReactiveError::HandlerFailed {
5296                    handler_id: registered.id.clone(),
5297                    source,
5298                })?;
5299
5300            if let Err(error) =
5301                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5302            {
5303                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5304                    self.metrics
5305                        .pending_contamination
5306                        .fetch_add(1, Ordering::Relaxed);
5307                }
5308                return Err(error);
5309            }
5310            executions.push(HandlerExecution::from_outcome(
5311                registered.id.clone(),
5312                input_ref,
5313                outcome,
5314                matches!(
5315                    record.context.chain_status,
5316                    ChainStatus::Preconfirmed { .. }
5317                ),
5318            ));
5319        }
5320        Ok(executions)
5321    }
5322
5323    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5324        for report in reports {
5325            for hook in &self.hooks {
5326                hook.on_report(report.clone());
5327            }
5328        }
5329    }
5330
5331    fn apply_chain_control(
5332        &mut self,
5333        cache: &mut EvmCache,
5334        control: ChainControl,
5335        batch_report: &mut ReactiveBatchReport<N>,
5336        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5337    ) {
5338        match &control {
5339            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5340            ChainControl::Finalized(block) => {
5341                set_or_enrich_block_ref(&mut self.finalized_head, block);
5342            }
5343            ChainControl::CanonicalProgress(block)
5344            | ChainControl::Barrier {
5345                block: Some(block), ..
5346            } => {
5347                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5348                    optional_block_refs_are_compatible(Some(current), Some(block))
5349                });
5350                cache.advance_compact_block(
5351                    block.number,
5352                    block.hash,
5353                    block.timestamp,
5354                    preserve_env,
5355                );
5356                advance_or_enrich_coverage(&mut self.coverage_head, block);
5357                let enriched = self.journal_entry_mut(block).block;
5358                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5359                self.trim_journal();
5360            }
5361            ChainControl::Barrier { block: None, .. } => {}
5362            ChainControl::Reorg {
5363                common_ancestor,
5364                old_tip,
5365                ..
5366            } => {
5367                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5368                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5369                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5370                    entry.block.number == common_ancestor.number
5371                        && entry.block.hash == common_ancestor.hash
5372                }) {
5373                    self.drain_journal_after(ancestor_index)
5374                } else {
5375                    // Sparse journals are expected for blocks with no matching
5376                    // events. If the oldest retained entry is at or below the
5377                    // ancestor, every effect above it is still present and the
5378                    // rollback is complete even without an exact anchor.
5379                    if self
5380                        .journal
5381                        .front()
5382                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5383                    {
5384                        reports.extend(
5385                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5386                        );
5387                    }
5388                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5389                };
5390
5391                let reorg_report = self
5392                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5393                    .unwrap_or_else(|| ReorgReport {
5394                        dropped: Some(*old_tip),
5395                        dropped_blocks: Vec::new(),
5396                        dropped_inputs: Vec::new(),
5397                        rollback_updates: Vec::new(),
5398                        rollback_diff: StateDiff::default(),
5399                        purge_updates: Vec::new(),
5400                        purge_diff: StateDiff::default(),
5401                        canceled_resyncs: self
5402                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5403                        reason: ReorgReason::Explicit,
5404                        _network: PhantomData,
5405                    });
5406                remove_canceled_resyncs_from_batch(
5407                    &mut batch_report.resyncs,
5408                    &reorg_report.canceled_resyncs,
5409                );
5410                self.metrics
5411                    .reorgs_recovered
5412                    .fetch_add(1, Ordering::Relaxed);
5413                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5414
5415                if self.safe_head.as_ref().is_some_and(|head| {
5416                    head.number > common_ancestor.number
5417                        || (head.number == common_ancestor.number
5418                            && head.hash != common_ancestor.hash)
5419                }) {
5420                    self.safe_head = None;
5421                }
5422                if self.finalized_head.as_ref().is_some_and(|head| {
5423                    head.number > common_ancestor.number
5424                        || (head.number == common_ancestor.number
5425                            && head.hash != common_ancestor.hash)
5426                }) {
5427                    self.finalized_head = None;
5428                }
5429                let mut enriched_ancestor = *common_ancestor;
5430                if let Some(entry) = self.journal.iter().find(|entry| {
5431                    entry.block.number == common_ancestor.number
5432                        && entry.block.hash == common_ancestor.hash
5433                }) {
5434                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5435                }
5436                if let Some(current) = self.coverage_head.as_ref()
5437                    && current.number == common_ancestor.number
5438                    && current.hash == common_ancestor.hash
5439                {
5440                    enrich_block_ref(&mut enriched_ancestor, current);
5441                }
5442                self.coverage_head = Some(enriched_ancestor);
5443                cache.advance_compact_block(
5444                    enriched_ancestor.number,
5445                    enriched_ancestor.hash,
5446                    enriched_ancestor.timestamp,
5447                    false,
5448                );
5449                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5450                self.coverage_head = Some(enriched_ancestor);
5451                self.trim_journal();
5452            }
5453        }
5454        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5455            control,
5456        })));
5457    }
5458
5459    fn validate_ingest_sequence(
5460        &self,
5461        pre_record_controls: &[ChainControl],
5462        post_record_controls: &[ChainControl],
5463        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5464    ) -> Result<ChainControlState, ReactiveError> {
5465        let mut controls =
5466            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
5467        controls.extend_from_slice(pre_record_controls);
5468        controls.extend_from_slice(post_record_controls);
5469        let state = CanonicalSequenceState::new(
5470            self.journal.iter().map(|entry| entry.block).collect(),
5471            self.coverage_head,
5472            self.safe_head,
5473            self.finalized_head,
5474        );
5475        let record_metadata = records
5476            .iter()
5477            .map(|(record, _, scope)| (record, *scope))
5478            .collect::<Vec<_>>();
5479        let validation = validate_canonical_sequence_parts(
5480            &state,
5481            &controls,
5482            &record_metadata,
5483            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
5484        )
5485        .map_err(CanonicalSequenceError::into_reactive_error)?;
5486        let mut resolved_canonical_blocks = HashMap::new();
5487        for mutation in validation.mutations() {
5488            if let CanonicalSequenceMutation::Canonical(block) = mutation {
5489                resolved_canonical_blocks
5490                    .entry((block.number, block.hash))
5491                    .and_modify(|known| enrich_block_ref(known, block))
5492                    .or_insert(*block);
5493            }
5494        }
5495        Ok(ChainControlState {
5496            journal_invalidated_from: pre_record_controls
5497                .iter()
5498                .filter_map(|control| match control {
5499                    ChainControl::Reorg {
5500                        common_ancestor, ..
5501                    } => Some(common_ancestor.number.saturating_add(1)),
5502                    _ => None,
5503                })
5504                .min(),
5505            resolved_canonical_blocks,
5506        })
5507    }
5508
5509    fn recover_for_canonical_input(
5510        &mut self,
5511        cache: &mut EvmCache,
5512        block: &BlockRef,
5513        gap_is_certified: bool,
5514        parentless_replacement_is_proven: bool,
5515        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5516    ) -> Option<ReorgReport<N>> {
5517        let latest = self
5518            .coverage_head
5519            .or_else(|| self.journal.back().map(|entry| entry.block))?;
5520
5521        if latest.number == block.number && latest.hash == block.hash {
5522            return None;
5523        }
5524
5525        if self
5526            .journal
5527            .iter()
5528            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
5529        {
5530            return None;
5531        }
5532
5533        if latest.number.checked_add(1) == Some(block.number)
5534            && (block.parent_hash == Some(latest.hash)
5535                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
5536        {
5537            return None;
5538        }
5539
5540        if latest
5541            .number
5542            .checked_add(1)
5543            .is_some_and(|next| block.number > next)
5544        {
5545            // A forward gap: blocks between the journaled head and the arriving
5546            // block were never observed (e.g. a disconnect). A historical
5547            // canonical-progress delivery can instead be covered by a
5548            // compatible post-record progress/barrier certificate proving the
5549            // sparse interval contained no matching events. Live canonical
5550            // gaps remain observable and escalate health.
5551            if !gap_is_certified {
5552                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
5553                health_reports.extend(self.escalate_trust(block.number));
5554                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
5555                    MissedRangeReport {
5556                        from: latest.number + 1,
5557                        to: block.number - 1,
5558                        block: block.number,
5559                        _network: PhantomData,
5560                    },
5561                )));
5562            }
5563            return None;
5564        }
5565
5566        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
5567            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
5568                entry.block.number.checked_add(1) == Some(block.number)
5569                    && entry.block.hash == parent_hash
5570            }) {
5571                let parent = self.journal[parent_index].block;
5572                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
5573                (self.drain_journal_after(parent_index), Some(parent))
5574            } else {
5575                // An unknown immediate parent proves exactly N-1 and nothing
5576                // earlier. Preserve a prefix only when the accepted path is an
5577                // immediate child of the runtime's exact finalized anchor;
5578                // otherwise every cached BLOCKHASH may belong to the displaced
5579                // branch and must be cleared fail-closed.
5580                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
5581                    finalized.number.checked_add(1) == Some(block.number)
5582                        && parent_hash == finalized.hash
5583                });
5584                let invalidated_from = proven_finalized_anchor
5585                    .map_or(0, |finalized| finalized.number.saturating_add(1));
5586                cache.invalidate_cached_block_hashes_from(invalidated_from);
5587                if block.number > 0 {
5588                    // Even when the parent falls outside the retained journal,
5589                    // the arriving child authenticates its exact hash. Restore
5590                    // that one known value after clearing the displaced branch.
5591                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
5592                }
5593                health_reports.extend(self.warn_under_recovery(block.number));
5594                let dropped = if let Some(finalized) = proven_finalized_anchor {
5595                    self.drain_journal_from_number(finalized.number.saturating_add(1))
5596                } else {
5597                    self.drain_journal_from_number(0)
5598                };
5599                (dropped, proven_finalized_anchor)
5600            }
5601        } else {
5602            // No parent identity authenticates any prefix of the arriving path.
5603            cache.invalidate_cached_block_hashes_from(0);
5604            health_reports.extend(self.warn_under_recovery(block.number));
5605            (self.drain_journal_from_number(0), None)
5606        };
5607
5608        self.rebase_validation_state_from(
5609            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
5610        );
5611        let report = self
5612            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
5613            .or_else(|| {
5614                Some(ReorgReport {
5615                    dropped: Some(latest),
5616                    dropped_blocks: Vec::new(),
5617                    dropped_inputs: Vec::new(),
5618                    rollback_updates: Vec::new(),
5619                    rollback_diff: StateDiff::default(),
5620                    purge_updates: Vec::new(),
5621                    purge_diff: StateDiff::default(),
5622                    canceled_resyncs: self
5623                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
5624                    reason: ReorgReason::ParentMismatch,
5625                    _network: PhantomData,
5626                })
5627            });
5628        self.coverage_head = authenticated_anchor;
5629        for head in [&mut self.safe_head, &mut self.finalized_head] {
5630            if head.is_some_and(|head| {
5631                authenticated_anchor.is_none_or(|anchor| {
5632                    head.number > anchor.number
5633                        || (head.number == anchor.number && head.hash != anchor.hash)
5634                })
5635            }) {
5636                *head = None;
5637            }
5638        }
5639        if let Some(anchor) = authenticated_anchor {
5640            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
5641        }
5642        report
5643    }
5644
5645    fn recover_for_reorged_input(
5646        &mut self,
5647        cache: &mut EvmCache,
5648        record: &ReactiveInputRecord<N>,
5649        batch_dropped: &mut BatchDroppedCanonical,
5650        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5651    ) -> Option<ReorgReport<N>> {
5652        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
5653        if batch_dropped.contains(&incoming_dropped_block) {
5654            // A previous signal in this atomic batch already drained this
5655            // block/span. Preserve the lifecycle input report, but do not
5656            // repeat rollback or classify the provider's per-log removals as a
5657            // deep reorg. Exact hash-pinned repairs still need cancellation.
5658            let canceled_resyncs = self
5659                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
5660            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
5661                dropped: Some(incoming_dropped_block),
5662                dropped_blocks: vec![incoming_dropped_block],
5663                dropped_inputs: Vec::new(),
5664                rollback_updates: Vec::new(),
5665                rollback_diff: StateDiff::default(),
5666                purge_updates: Vec::new(),
5667                purge_diff: StateDiff::default(),
5668                canceled_resyncs,
5669                reason,
5670                _network: PhantomData,
5671            });
5672        }
5673        let exact_index = self.journal.iter().position(|entry| {
5674            entry.block.number == incoming_dropped_block.number
5675                && entry.block.hash == incoming_dropped_block.hash
5676        });
5677        let mut dropped_block = exact_index
5678            .map(|index| self.journal[index].block)
5679            .or_else(|| {
5680                self.coverage_head.filter(|known| {
5681                    known.number == incoming_dropped_block.number
5682                        && known.hash == incoming_dropped_block.hash
5683                })
5684            })
5685            .unwrap_or(incoming_dropped_block);
5686        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
5687        let replacement_is_known = exact_index.is_none()
5688            && (self.journal.iter().any(|entry| {
5689                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
5690            }) || self.coverage_head.is_some_and(|head| {
5691                head.number == dropped_block.number && head.hash != dropped_block.hash
5692            }));
5693
5694        if replacement_is_known {
5695            // A delayed/duplicate removed log for the displaced hash is
5696            // idempotent. Draining by number here would destroy the already
5697            // installed replacement branch at the same height.
5698            let canceled_resyncs =
5699                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
5700            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
5701                dropped: Some(dropped_block),
5702                dropped_blocks: vec![dropped_block],
5703                dropped_inputs: Vec::new(),
5704                rollback_updates: Vec::new(),
5705                rollback_diff: StateDiff::default(),
5706                purge_updates: Vec::new(),
5707                purge_diff: StateDiff::default(),
5708                canceled_resyncs,
5709                reason,
5710                _network: PhantomData,
5711            });
5712        }
5713
5714        let authenticated_anchor = exact_index.and_then(|index| {
5715            let ancestor_number = dropped_block.number.checked_sub(1)?;
5716            let retained = self
5717                .journal
5718                .iter()
5719                .take(index)
5720                .rev()
5721                .find(|entry| entry.block.number == ancestor_number)
5722                .map(|entry| entry.block);
5723            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
5724                number: ancestor_number,
5725                hash,
5726                parent_hash: None,
5727                timestamp: None,
5728            });
5729            let finalized_fallback = self
5730                .finalized_head
5731                .filter(|head| head.number == ancestor_number);
5732            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
5733            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
5734                .into_iter()
5735                .flatten()
5736            {
5737                if head.number == anchor.number && head.hash == anchor.hash {
5738                    enrich_block_ref(&mut anchor, head);
5739                }
5740            }
5741            Some(anchor)
5742        });
5743
5744        cache.invalidate_cached_block_hashes_from(dropped_block.number);
5745        let dropped = if let Some(index) = exact_index {
5746            self.drain_journal_from(index)
5747        } else {
5748            health_reports.extend(self.warn_under_recovery(dropped_block.number));
5749            self.drain_journal_from_number(dropped_block.number)
5750        };
5751        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
5752        batch_dropped.record_drained(&drained_blocks);
5753        batch_dropped.record_identity(&dropped_block);
5754        self.rebase_validation_state_from(dropped_block.number);
5755
5756        let recovered_journal = !dropped.is_empty();
5757        let report = if !recovered_journal {
5758            let canceled_resyncs =
5759                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
5760            Some(ReorgReport {
5761                dropped: Some(dropped_block),
5762                dropped_blocks: Vec::new(),
5763                dropped_inputs: Vec::new(),
5764                rollback_updates: Vec::new(),
5765                rollback_diff: StateDiff::default(),
5766                purge_updates: Vec::new(),
5767                purge_diff: StateDiff::default(),
5768                canceled_resyncs,
5769                reason,
5770                _network: PhantomData,
5771            })
5772        } else {
5773            self.recover_dropped_journals(cache, dropped, reason)
5774        };
5775
5776        if recovered_journal {
5777            if let Some(anchor) = authenticated_anchor {
5778                self.coverage_head = Some(anchor);
5779            }
5780            let coverage = self.coverage_head;
5781            for head in [&mut self.safe_head, &mut self.finalized_head] {
5782                if head.is_some_and(|head| {
5783                    coverage.is_none_or(|coverage| {
5784                        head.number > coverage.number
5785                            || (head.number == coverage.number && head.hash != coverage.hash)
5786                    })
5787                }) {
5788                    *head = None;
5789                }
5790            }
5791        }
5792
5793        if recovered_journal
5794            && report.is_some()
5795            && let Some(head) = self.coverage_head
5796        {
5797            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
5798        }
5799        report
5800    }
5801
5802    /// Warn that a reorg references a block no longer resident in the journal, so
5803    /// recovery is limited to the blocks still journaled — effects from aged-out
5804    /// blocks are neither rolled back nor purged (the freshness/validation loop is
5805    /// the backstop). Makes the under-recovery observable instead of silent.
5806    ///
5807    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
5808    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
5809    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
5810    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
5811    /// transition is returned so the caller can thread it into the ingest cycle's
5812    /// dispatched reports.
5813    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
5814        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
5815        tracing::warn!(
5816            reorg_block = reorg_number,
5817            oldest_journaled = ?oldest_journaled,
5818            journal_depth = self.config.journal_depth,
5819            "reactive reorg recovery is incomplete: the reorged block is no longer \
5820             in the journal, so effects from blocks aged out of the journal are \
5821             neither rolled back nor purged (the freshness/validation loop is the \
5822             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
5823             reorgs precisely."
5824        );
5825
5826        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
5827
5828        self.escalate_trust(reorg_number)
5829    }
5830
5831    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
5832        advance_or_enrich_coverage(&mut self.coverage_head, block);
5833        let entry = self.journal_entry_mut(block);
5834        let enriched = entry.block;
5835        if !entry.inputs.contains(&input_ref) {
5836            entry.inputs.push(input_ref);
5837        }
5838        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5839        self.trim_journal();
5840    }
5841
5842    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
5843        let entry = self.journal_entry_mut(block);
5844        if !entry.handler_ids.contains(&applied.handler_id) {
5845            entry.handler_ids.push(applied.handler_id.clone());
5846        }
5847        entry.rollback_diffs.push(applied.diff.clone());
5848        entry.applied.push(applied);
5849        self.trim_journal();
5850    }
5851
5852    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
5853        let Some(entry) = self
5854            .journal
5855            .iter_mut()
5856            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
5857        else {
5858            return;
5859        };
5860        if !entry.handler_ids.contains(&applied.handler_id) {
5861            entry.handler_ids.push(applied.handler_id.clone());
5862        }
5863        entry.rollback_diffs.push(applied.diff.clone());
5864        entry.applied.push(applied);
5865    }
5866
5867    fn record_journal_resync(&mut self, report: &ResyncReport) {
5868        if report.diff.is_empty() {
5869            return;
5870        }
5871        let Some(block) = single_hash_pinned_resync_block(report) else {
5872            return;
5873        };
5874        let entry = self.journal_entry_mut(&block);
5875        entry.rollback_diffs.push(report.diff.clone());
5876        entry.resynced.push(report.clone());
5877        self.trim_journal();
5878    }
5879
5880    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
5881        if let Some(index) = self
5882            .journal
5883            .iter()
5884            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
5885        {
5886            enrich_block_ref(&mut self.journal[index].block, block);
5887            return &mut self.journal[index];
5888        }
5889
5890        self.journal.push_back(BlockJournal {
5891            block: *block,
5892            inputs: Vec::new(),
5893            applied: Vec::new(),
5894            handler_ids: Vec::new(),
5895            resynced: Vec::new(),
5896            rollback_diffs: Vec::new(),
5897        });
5898        let index = self.journal.len() - 1;
5899        &mut self.journal[index]
5900    }
5901
5902    fn trim_journal(&mut self) {
5903        if self.config.journal_depth == 0 {
5904            self.journal.clear();
5905            return;
5906        }
5907        while self.journal.len() > self.config.journal_depth {
5908            self.journal.pop_front();
5909        }
5910    }
5911
5912    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
5913        self.journal.drain((index + 1)..).collect()
5914    }
5915
5916    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
5917        self.journal.drain(index..).collect()
5918    }
5919
5920    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
5921        let Some(index) = self
5922            .journal
5923            .iter()
5924            .position(|entry| entry.block.number >= number)
5925        else {
5926            return Vec::new();
5927        };
5928        self.drain_journal_from(index)
5929    }
5930
5931    fn recover_dropped_journals(
5932        &mut self,
5933        cache: &mut EvmCache,
5934        dropped: Vec<BlockJournal<N>>,
5935        reason: ReorgReason,
5936    ) -> Option<ReorgReport<N>> {
5937        if dropped.is_empty() {
5938            return None;
5939        }
5940
5941        let first_dropped_block = dropped
5942            .iter()
5943            .map(|entry| entry.block.number)
5944            .min()
5945            .expect("non-empty dropped journal set");
5946        self.rebase_validation_state_from(first_dropped_block);
5947        if self
5948            .safe_head
5949            .is_some_and(|head| head.number >= first_dropped_block)
5950        {
5951            self.safe_head = None;
5952        }
5953
5954        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
5955        let dropped_inputs: Vec<_> = dropped
5956            .iter()
5957            .flat_map(|entry| entry.inputs.iter().copied())
5958            .collect();
5959        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
5960        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
5961        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
5962        let purge_updates: Vec<_> = purge_scopes
5963            .iter()
5964            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
5965            .collect();
5966
5967        let rollback_diff = if rollback_updates.is_empty() {
5968            StateDiff::default()
5969        } else {
5970            cache.apply_updates(&rollback_updates)
5971        };
5972        let purge_diff = if purge_updates.is_empty() {
5973            StateDiff::default()
5974        } else {
5975            cache.apply_updates(&purge_updates)
5976        };
5977        self.coverage_head = self.journal.back().map(|entry| entry.block);
5978
5979        Some(ReorgReport {
5980            dropped: dropped_blocks.first().cloned(),
5981            dropped_blocks,
5982            dropped_inputs,
5983            rollback_updates,
5984            rollback_diff,
5985            purge_updates,
5986            purge_diff,
5987            canceled_resyncs,
5988            reason,
5989            _network: PhantomData,
5990        })
5991    }
5992
5993    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
5994        if let Some(freshness) = self.freshness.as_mut() {
5995            freshness.invalidate_valid_through_from(first_dropped_block);
5996        }
5997        self.tracked_roots
5998            .retain(|_, baseline| baseline.last_block < first_dropped_block);
5999        if self
6000            .last_gate_block
6001            .is_some_and(|block| block >= first_dropped_block)
6002        {
6003            self.last_gate_block = self
6004                .tracked_roots
6005                .values()
6006                .map(|baseline| baseline.last_block)
6007                .max();
6008        }
6009        // Touch provenance is window-relative. Once any block in that window
6010        // is dropped, retaining the union could incorrectly mark a replacement
6011        // branch root move as decoder-covered.
6012        self.touched_since_gate.clear();
6013    }
6014
6015    fn cancel_resyncs_for_dropped_blocks(
6016        &mut self,
6017        dropped_blocks: &[BlockRef],
6018    ) -> Vec<ResyncRequest> {
6019        let mut canceled = Vec::new();
6020        self.pending_resyncs.retain(|request| {
6021            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6022            if should_cancel {
6023                canceled.push(request.clone());
6024            }
6025            !should_cancel
6026        });
6027        canceled
6028    }
6029
6030    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6031        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6032        self.pending_resyncs
6033            .retain(|request| !ids.contains(&request.id));
6034    }
6035}
6036
6037/// Validate one provider-neutral delivery envelope without mutating runtime or
6038/// cache state.
6039///
6040/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6041/// composite/remote subscribers. It validates explicit reorg controls before
6042/// records, canonical record identity and implicit-reorg finality, then
6043/// progress/barrier/safe/finalized controls. All identity assertions in the
6044/// envelope must agree at each height. Retained history may be sparse; an
6045/// explicit common ancestor need not itself be retained when the oldest
6046/// retained entry is at or below it. Ancestors and removed blocks outside that
6047/// rollback horizon are rejected, so a durable caller cannot persist a partial
6048/// rollback. The runtime uses this same implementation with an internal
6049/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6050///
6051/// The returned state and mutations are cache-free. Callers that durably stage
6052/// delivery should publish/persist them only at their own acknowledgement
6053/// boundary.
6054///
6055/// This validator is deliberately chain-agnostic and does not compare
6056/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6057/// no chain id. Cross-service/composite callers must bind one authoritative
6058/// chain identity outside this state before sharing or advancing it; runtime
6059/// ingestion separately checks the batch id against [`EvmCache`].
6060///
6061/// # Errors
6062///
6063/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6064/// metadata is malformed or conflicting, and
6065/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6066/// invalid canonical transition, incomplete rollback proof, contradictory
6067/// identity, or invalid coverage/finality relationship.
6068pub fn validate_canonical_sequence<N: Network>(
6069    state: &CanonicalSequenceState,
6070    batch: &ReactiveInputBatch<N>,
6071) -> Result<CanonicalSequenceValidation, ReactiveError> {
6072    validate_canonical_sequence_diagnostic(state, batch)
6073        .map_err(CanonicalSequenceError::into_reactive_error)
6074}
6075
6076/// Validate one provider-neutral delivery envelope and retain structured
6077/// rollback diagnostics.
6078///
6079/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6080/// it at durable/composite source boundaries that need to distinguish malformed
6081/// input from an otherwise valid transition whose rollback ancestor has aged
6082/// out of the retained history. Callers should branch on
6083/// [`CanonicalSequenceError`] rather than parsing error text.
6084///
6085/// # Errors
6086///
6087/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6088/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6089/// retained canonical history is required to prove the transition.
6090pub fn validate_canonical_sequence_diagnostic<N: Network>(
6091    state: &CanonicalSequenceState,
6092    batch: &ReactiveInputBatch<N>,
6093) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6094    validate_canonical_sequence_internal(
6095        state,
6096        batch,
6097        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6098    )
6099}
6100
6101/// Validate a composite-source envelope and normalize harmless coverage
6102/// overlap.
6103///
6104/// This has the same fail-closed rollback/finality/identity contract as
6105/// [`validate_canonical_sequence`]. In addition, an equal or older
6106/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6107/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6108/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6109/// same opaque id and `block: None`, preserving the synchronization event
6110/// without forwarding regressive coverage. An equal-height control that fills
6111/// absent parent/timestamp metadata is retained and applied. Older compatible
6112/// metadata enrichment is deliberately dropped together with its non-forwarded
6113/// control so the returned state remains identical to what the runtime will
6114/// observe. Unknown or conflicting stale identities remain errors.
6115///
6116/// # Errors
6117///
6118/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6119/// record identity/payload metadata, and
6120/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6121/// proven redundant or when rollback, adjacency, identity, coverage, or
6122/// finality validation fails.
6123pub fn normalize_and_validate_canonical_sequence<N: Network>(
6124    state: &CanonicalSequenceState,
6125    batch: &ReactiveInputBatch<N>,
6126) -> Result<CanonicalSequenceValidation, ReactiveError> {
6127    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6128        .map_err(CanonicalSequenceError::into_reactive_error)
6129}
6130
6131/// Validate and normalize one composite-source envelope while retaining
6132/// structured rollback diagnostics.
6133///
6134/// This is the diagnostic counterpart to
6135/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6136/// and normalization semantics, but reports history exhaustion as
6137/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6138/// prose [`ReactiveError::InvalidChainControl`].
6139///
6140/// # Errors
6141///
6142/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6143/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6144/// when the retained history cannot prove a complete rollback.
6145pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6146    state: &CanonicalSequenceState,
6147    batch: &ReactiveInputBatch<N>,
6148) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6149    validate_canonical_sequence_internal(
6150        state,
6151        batch,
6152        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6153    )
6154}
6155
6156fn validate_canonical_sequence_internal<N: Network>(
6157    state: &CanonicalSequenceState,
6158    batch: &ReactiveInputBatch<N>,
6159    policy: CanonicalSequenceValidationPolicy,
6160) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6161    let records = batch
6162        .records()
6163        .iter()
6164        .enumerate()
6165        .map(|(index, record)| {
6166            (
6167                record.clone(),
6168                DeliveryAudience::All,
6169                batch
6170                    .record_delivery_scope(index)
6171                    .expect("enumerated record always has a delivery scope"),
6172            )
6173        })
6174        .collect::<Vec<_>>();
6175    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6176    let records = records
6177        .iter()
6178        .map(|(record, _, scope)| (record, *scope))
6179        .collect::<Vec<_>>();
6180    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6181}
6182
6183#[derive(Clone, Copy)]
6184enum CanonicalSequenceValidationPolicy {
6185    RequireCompleteRollback,
6186    RequireCompleteRollbackNormalizeCoverage,
6187    ObserveIncompleteRollback,
6188}
6189
6190/// Stable category for a canonical transition that needs older retained
6191/// history before it can be durably accepted.
6192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6193#[non_exhaustive]
6194pub enum CanonicalRollbackKind {
6195    /// An explicit reorg control names an ancestor outside retained history.
6196    Explicit,
6197    /// A removed/reorged record names a block outside retained history.
6198    Removed,
6199    /// An implicit canonical replacement has no retained parent proof.
6200    ImplicitParent,
6201    /// A removed block is not followed by a provable replacement/anchor.
6202    MissingReplacement,
6203}
6204
6205/// Structured failure returned by canonical-sequence diagnostic validation.
6206///
6207/// This type is intentionally independent of diagnostic prose so remote and
6208/// composite subscribers can select recovery behavior without string matching.
6209#[derive(Debug, thiserror::Error)]
6210#[non_exhaustive]
6211pub enum CanonicalSequenceError {
6212    /// The snapshot or envelope is intrinsically malformed or contradictory.
6213    #[error(transparent)]
6214    Invalid(#[from] ReactiveError),
6215    /// The transition may be valid, but its rollback proof lies outside the
6216    /// supplied retained canonical history.
6217    #[error(
6218        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6219    )]
6220    IncompleteRollback {
6221        /// Last ancestor height required to prove the rollback.
6222        common_ancestor: u64,
6223        /// Oldest retained canonical height supplied by the caller.
6224        oldest_retained: Option<u64>,
6225        /// Stable reason the history window is insufficient.
6226        kind: CanonicalRollbackKind,
6227    },
6228}
6229
6230#[derive(Clone, Copy, Debug)]
6231struct RequiredReorgAnchor {
6232    number: u64,
6233    block: Option<BlockRef>,
6234    permits_missing_child_parent: bool,
6235    must_be_consumed: bool,
6236}
6237
6238#[derive(Debug)]
6239struct SequenceRewind {
6240    common_ancestor: Option<BlockRef>,
6241    dropped: Vec<BlockRef>,
6242}
6243
6244impl RequiredReorgAnchor {
6245    const fn hash(self) -> Option<B256> {
6246        match self.block {
6247            Some(block) => Some(block.hash),
6248            None => None,
6249        }
6250    }
6251}
6252
6253impl CanonicalSequenceError {
6254    /// Whether retrying with an older retained history window may prove this
6255    /// same transition.
6256    pub const fn requires_history(&self) -> bool {
6257        matches!(self, Self::IncompleteRollback { .. })
6258    }
6259
6260    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6261    pub fn into_reactive_error(self) -> ReactiveError {
6262        match self {
6263            Self::Invalid(error) => error,
6264            Self::IncompleteRollback {
6265                common_ancestor,
6266                oldest_retained,
6267                kind,
6268            } => ReactiveError::InvalidChainControl {
6269                message: format!(
6270                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6271                ),
6272            },
6273        }
6274    }
6275}
6276
6277impl CanonicalSequenceValidationPolicy {
6278    const fn requires_complete_rollback(self) -> bool {
6279        matches!(
6280            self,
6281            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6282        )
6283    }
6284
6285    const fn normalizes_coverage(self) -> bool {
6286        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6287    }
6288}
6289
6290fn validate_canonical_sequence_parts<N: Network>(
6291    initial: &CanonicalSequenceState,
6292    controls: &[ChainControl],
6293    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6294    policy: CanonicalSequenceValidationPolicy,
6295) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6296    validate_canonical_sequence_snapshot(initial)?;
6297    let control_split = validate_control_phase_order(controls)?;
6298    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6299    let mut state = initial.clone();
6300    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6301    let mut mutations = Vec::new();
6302    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6303    let mut batch_dropped = BatchDroppedCanonical::default();
6304    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6305    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6306    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6307    let rollback_oldest = initial
6308        .retained_canonical_history
6309        .first()
6310        .map(|block| block.number);
6311
6312    for control in pre_record_controls {
6313        normalized_chain_controls.push(control.clone());
6314        validate_sequence_control(&state, control)?;
6315        assert_chain_control_identities(&mut asserted_blocks, control)?;
6316        let ChainControl::Reorg {
6317            common_ancestor,
6318            old_tip,
6319            ..
6320        } = control
6321        else {
6322            unreachable!("phase validation leaves only reorg controls before records")
6323        };
6324        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6325            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6326        });
6327        let rollback_horizon_covers_ancestor = state
6328            .retained_canonical_history
6329            .first()
6330            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6331        if policy.requires_complete_rollback()
6332            && !exact_ancestor
6333            && !rollback_horizon_covers_ancestor
6334        {
6335            return Err(CanonicalSequenceError::IncompleteRollback {
6336                common_ancestor: common_ancestor.number,
6337                oldest_retained: rollback_oldest,
6338                kind: CanonicalRollbackKind::Explicit,
6339            });
6340        }
6341        let dropped = state
6342            .retained_canonical_history
6343            .iter()
6344            .copied()
6345            .filter(|block| block.number > common_ancestor.number)
6346            .collect::<Vec<_>>();
6347        state
6348            .retained_canonical_history
6349            .retain(|block| block.number <= common_ancestor.number);
6350        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6351        let mut enriched_ancestor = *common_ancestor;
6352        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6353            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6354        }) {
6355            enrich_block_ref(&mut enriched_ancestor, retained);
6356        }
6357        if let Some(coverage) = state.coverage_head.as_ref()
6358            && coverage.number == common_ancestor.number
6359            && coverage.hash == common_ancestor.hash
6360        {
6361            enrich_block_ref(&mut enriched_ancestor, coverage);
6362        }
6363        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6364        state.coverage_head = Some(enriched_ancestor);
6365        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6366        batch_dropped.record_explicit(common_ancestor, old_tip);
6367        batch_dropped.record_drained(&dropped);
6368        mutations.push(CanonicalSequenceMutation::Rewind {
6369            common_ancestor: Some(enriched_ancestor),
6370            dropped,
6371        });
6372    }
6373    let pre_record_state = state.clone();
6374    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6375
6376    for (record, scope) in records {
6377        if !scope.advances_canonical_state() {
6378            continue;
6379        }
6380        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6381            let incoming_dropped_block =
6382                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6383            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6384            validate_sequence_adjacent_parent_identity(
6385                &state,
6386                &incoming_dropped_block,
6387                "removed record",
6388            )?;
6389            let mut dropped_block = state
6390                .retained_canonical_history
6391                .iter()
6392                .find(|known| {
6393                    known.number == incoming_dropped_block.number
6394                        && known.hash == incoming_dropped_block.hash
6395                })
6396                .copied()
6397                .or_else(|| {
6398                    state.coverage_head.filter(|known| {
6399                        known.number == incoming_dropped_block.number
6400                            && known.hash == incoming_dropped_block.hash
6401                    })
6402                })
6403                .unwrap_or(incoming_dropped_block);
6404            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6405            validate_sequence_implicit_finality(&state, record, None)?;
6406            if dropped_block.number == 0 {
6407                return Err(ReactiveError::InvalidChainControl {
6408                    message: "a removed/reorged genesis block has no canonical parent anchor"
6409                        .into(),
6410                }
6411                .into());
6412            }
6413            let removed_identity = (dropped_block.number, dropped_block.hash);
6414            if let Some(previous_number) =
6415                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6416                && previous_number != dropped_block.number
6417            {
6418                return Err(ReactiveError::InvalidChainControl {
6419                    message: format!(
6420                        "removed hash {:?} is reused at heights {} and {}",
6421                        dropped_block.hash, previous_number, dropped_block.number
6422                    ),
6423                }
6424                .into());
6425            }
6426            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6427                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6428                    return Err(ReactiveError::InvalidChainControl {
6429                        message: format!(
6430                            "duplicate removed block {}:{:?} carries conflicting metadata",
6431                            dropped_block.number, dropped_block.hash
6432                        ),
6433                    }
6434                    .into());
6435                }
6436                enrich_block_ref(previous, &dropped_block);
6437            } else {
6438                removed_assertions.insert(removed_identity, dropped_block);
6439            }
6440            if asserted_blocks
6441                .get(&dropped_block.number)
6442                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6443            {
6444                return Err(ReactiveError::InvalidChainControl {
6445                    message: format!(
6446                        "removed block {}:{:?} is asserted canonical by the same envelope",
6447                        dropped_block.number, dropped_block.hash
6448                    ),
6449                }
6450                .into());
6451            }
6452            if batch_dropped.contains(&dropped_block) {
6453                continue;
6454            }
6455            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
6456                block.number == dropped_block.number && block.hash == dropped_block.hash
6457            }) {
6458                let dropped = state.retained_canonical_history.split_off(index);
6459                batch_dropped.record_drained(&dropped);
6460                let ancestor_number = dropped_block
6461                    .number
6462                    .checked_sub(1)
6463                    .expect("genesis removal was rejected above");
6464                let retained_anchor = state
6465                    .retained_canonical_history
6466                    .iter()
6467                    .rev()
6468                    .find(|head| head.number == ancestor_number)
6469                    .copied();
6470                let authenticated_anchor = retained_anchor
6471                    .or_else(|| {
6472                        dropped_block.parent_hash.map(|hash| BlockRef {
6473                            number: ancestor_number,
6474                            hash,
6475                            parent_hash: None,
6476                            timestamp: None,
6477                        })
6478                    })
6479                    .or_else(|| {
6480                        state
6481                            .finalized_head
6482                            .filter(|head| head.number == ancestor_number)
6483                    });
6484                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
6485                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
6486                        .into_iter()
6487                        .flatten()
6488                    {
6489                        if head.number == anchor.number && head.hash == anchor.hash {
6490                            enrich_block_ref(&mut anchor, head);
6491                        }
6492                    }
6493                    anchor
6494                });
6495                required_reorg_anchor = Some(RequiredReorgAnchor {
6496                    number: ancestor_number,
6497                    block: authenticated_anchor,
6498                    permits_missing_child_parent: retained_anchor.is_some(),
6499                    must_be_consumed: authenticated_anchor.is_none()
6500                        && state.retained_canonical_history.is_empty(),
6501                });
6502                state.coverage_head = authenticated_anchor
6503                    .or_else(|| state.retained_canonical_history.last().copied());
6504                if let Some(head) = state.coverage_head {
6505                    clear_sequence_heads_above(&mut state, &head);
6506                } else {
6507                    state.safe_head = None;
6508                    state.finalized_head = None;
6509                }
6510                mutations.push(CanonicalSequenceMutation::Rewind {
6511                    common_ancestor: state.coverage_head,
6512                    dropped,
6513                });
6514            } else {
6515                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
6516                    block.number == dropped_block.number && block.hash != dropped_block.hash
6517                }) || state.coverage_head.is_some_and(|head| {
6518                    head.number == dropped_block.number && head.hash != dropped_block.hash
6519                });
6520                if !replacement_is_known {
6521                    // Ordinary runtime ingestion deliberately keeps an unknown
6522                    // deep removal observable and lets the recovery path
6523                    // degrade health. With no exact retained rollback proof,
6524                    // this validator must not fabricate a new canonical head.
6525                    if policy.requires_complete_rollback() {
6526                        return Err(CanonicalSequenceError::IncompleteRollback {
6527                            common_ancestor: dropped_block
6528                                .number
6529                                .checked_sub(1)
6530                                .expect("genesis removal was rejected above"),
6531                            oldest_retained: rollback_oldest,
6532                            kind: CanonicalRollbackKind::Removed,
6533                        });
6534                    }
6535                    continue;
6536                }
6537            }
6538            continue;
6539        }
6540
6541        let Some(context_block) = canonical_record_block(record) else {
6542            continue;
6543        };
6544        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
6545        if post_record_controls
6546            .iter()
6547            .filter_map(canonical_coverage_control_block)
6548            .any(|asserted| {
6549                asserted.number == incoming_block.number
6550                    && asserted.hash == incoming_block.hash
6551                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
6552                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
6553                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
6554            })
6555        {
6556            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
6557        }
6558        let mut resolved_block = incoming_block;
6559        if let Some(asserted) = asserted_blocks
6560            .get(&incoming_block.number)
6561            .filter(|asserted| asserted.hash == incoming_block.hash)
6562        {
6563            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
6564                return Err(ReactiveError::InvalidChainControl {
6565                    message: format!(
6566                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6567                        incoming_block.number, incoming_block.hash
6568                    ),
6569                }
6570                .into());
6571            }
6572            enrich_block_ref(&mut resolved_block, asserted);
6573        }
6574        for asserted in post_record_controls
6575            .iter()
6576            .filter_map(chain_control_canonical_assertion)
6577            .filter(|asserted| {
6578                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
6579            })
6580        {
6581            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
6582                return Err(ReactiveError::InvalidChainControl {
6583                    message: format!(
6584                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6585                        incoming_block.number, incoming_block.hash
6586                    ),
6587                }
6588                .into());
6589            }
6590            enrich_block_ref(&mut resolved_block, asserted);
6591        }
6592        let replacement_anchor =
6593            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
6594        if resolved_block.parent_hash.is_none()
6595            && replacement_anchor.is_some_and(|anchor| {
6596                anchor.permits_missing_child_parent
6597                    && anchor.number.checked_add(1) == Some(resolved_block.number)
6598            })
6599        {
6600            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
6601        }
6602        let block = &resolved_block;
6603        if removed_assertions.contains_key(&(block.number, block.hash)) {
6604            return Err(ReactiveError::InvalidChainControl {
6605                message: format!(
6606                    "canonical block {}:{:?} is also removed by the same envelope",
6607                    block.number, block.hash
6608                ),
6609            }
6610            .into());
6611        }
6612        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
6613            && *removed_number != block.number
6614        {
6615            return Err(ReactiveError::InvalidChainControl {
6616                message: format!(
6617                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
6618                    block.hash, block.number, removed_number
6619                ),
6620            }
6621            .into());
6622        }
6623        let replacement_proven_by_removal =
6624            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
6625        if replacement_anchor.is_some() {
6626            required_reorg_anchor = None;
6627        }
6628        validate_sequence_matching_metadata(&state, block, "canonical record")?;
6629        validate_sequence_implicit_finality(&state, record, Some(block))?;
6630        let implicit_replacement_requires_history = if replacement_proven_by_removal {
6631            false
6632        } else {
6633            sequence_implicit_replacement_requires_history(&state, block, policy)?
6634        };
6635        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
6636            return Err(CanonicalSequenceError::IncompleteRollback {
6637                common_ancestor: block.number.saturating_sub(1),
6638                oldest_retained: rollback_oldest,
6639                kind: CanonicalRollbackKind::ImplicitParent,
6640            });
6641        }
6642        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
6643        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
6644            anchor.permits_missing_child_parent
6645                && anchor.number.checked_add(1) == Some(block.number)
6646        });
6647        if let Some(rewind) =
6648            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
6649        {
6650            mutations.push(CanonicalSequenceMutation::Rewind {
6651                common_ancestor: rewind.common_ancestor,
6652                dropped: rewind.dropped,
6653            });
6654        }
6655        mutations.push(CanonicalSequenceMutation::Canonical(*block));
6656    }
6657
6658    for control in post_record_controls {
6659        if let Some(block) = chain_control_canonical_assertion(control)
6660            && removed_assertions.contains_key(&(block.number, block.hash))
6661        {
6662            return Err(ReactiveError::InvalidChainControl {
6663                message: format!(
6664                    "canonical block {}:{:?} is also removed by the same envelope",
6665                    block.number, block.hash
6666                ),
6667            }
6668            .into());
6669        }
6670        if let Some(block) = chain_control_canonical_assertion(control)
6671            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
6672            && *removed_number != block.number
6673        {
6674            return Err(ReactiveError::InvalidChainControl {
6675                message: format!(
6676                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
6677                    block.hash, block.number, removed_number
6678                ),
6679            }
6680            .into());
6681        }
6682        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
6683            required_reorg_anchor.filter(|required| block.number > required.number)
6684        });
6685        if let Some(block) = canonical_coverage_control_block(control) {
6686            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
6687            if replacement_anchor.is_some() {
6688                required_reorg_anchor = None;
6689            }
6690        }
6691        assert_chain_control_identities(&mut asserted_blocks, control)?;
6692        let preserves_record_proof =
6693            canonical_coverage_control_block(control).is_some_and(|block| {
6694                record_proof_control_identities.contains(&(block.number, block.hash))
6695            });
6696        if policy.normalizes_coverage()
6697            && !preserves_record_proof
6698            && let Some(block) = canonical_coverage_control_block(control)
6699            && state
6700                .coverage_head
6701                .is_some_and(|head| block.number <= head.number)
6702        {
6703            let is_equal_coverage = state
6704                .coverage_head
6705                .is_some_and(|head| block.number == head.number);
6706            let known = state
6707                .coverage_head
6708                .as_ref()
6709                .filter(|head| head.number == block.number && head.hash == block.hash)
6710                .or_else(|| {
6711                    state
6712                        .retained_canonical_history
6713                        .iter()
6714                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
6715                });
6716            if let Some(known) = known
6717                && optional_block_refs_are_compatible(Some(known), Some(block))
6718                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
6719            {
6720                if let ChainControl::Barrier { id, .. } = control {
6721                    normalized_chain_controls.push(ChainControl::Barrier {
6722                        id: id.clone(),
6723                        block: None,
6724                    });
6725                }
6726                continue;
6727            }
6728        }
6729        validate_sequence_control(&state, control)?;
6730        normalized_chain_controls.push(control.clone());
6731        match control {
6732            ChainControl::Safe(block) => {
6733                set_or_enrich_block_ref(&mut state.safe_head, block);
6734                mutations.push(CanonicalSequenceMutation::Safe(
6735                    state.safe_head.expect("safe head was just installed"),
6736                ));
6737            }
6738            ChainControl::Finalized(block) => {
6739                set_or_enrich_block_ref(&mut state.finalized_head, block);
6740                mutations.push(CanonicalSequenceMutation::Finalized(
6741                    state
6742                        .finalized_head
6743                        .expect("finalized head was just installed"),
6744                ));
6745            }
6746            ChainControl::CanonicalProgress(block)
6747            | ChainControl::Barrier {
6748                block: Some(block), ..
6749            } => {
6750                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
6751                    anchor.permits_missing_child_parent
6752                        && anchor.number.checked_add(1) == Some(block.number)
6753                }) || (replacement_anchor.is_none()
6754                    && block.parent_hash.is_none()
6755                    && state
6756                        .coverage_head
6757                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
6758                if let Some(rewind) =
6759                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
6760                {
6761                    mutations.push(CanonicalSequenceMutation::Rewind {
6762                        common_ancestor: rewind.common_ancestor,
6763                        dropped: rewind.dropped,
6764                    });
6765                }
6766                mutations.push(CanonicalSequenceMutation::Canonical(*block));
6767            }
6768            ChainControl::Barrier { block: None, .. } => {}
6769            ChainControl::Reorg { .. } => {
6770                unreachable!("phase validation excludes post-record reorg controls")
6771            }
6772        }
6773    }
6774
6775    if let Some(required) = required_reorg_anchor
6776        && required.must_be_consumed
6777        && policy.requires_complete_rollback()
6778    {
6779        return Err(CanonicalSequenceError::IncompleteRollback {
6780            common_ancestor: required.number,
6781            oldest_retained: rollback_oldest,
6782            kind: CanonicalRollbackKind::MissingReplacement,
6783        });
6784    }
6785
6786    validate_canonical_sequence_snapshot(&state)?;
6787    Ok(CanonicalSequenceValidation {
6788        pre_record_state,
6789        next_state: state,
6790        mutations,
6791        normalized_chain_controls,
6792    })
6793}
6794
6795fn validate_canonical_sequence_snapshot(
6796    state: &CanonicalSequenceState,
6797) -> Result<(), ReactiveError> {
6798    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
6799    let supplied_blocks = state
6800        .retained_canonical_history
6801        .iter()
6802        .chain(state.coverage_head.iter())
6803        .chain(state.safe_head.iter())
6804        .chain(state.finalized_head.iter())
6805        .collect::<Vec<_>>();
6806    validate_known_parent_hash_heights(&supplied_blocks)?;
6807    let mut prior = None::<BlockRef>;
6808    for block in &state.retained_canonical_history {
6809        if let Some(previous) = prior {
6810            if block.number < previous.number {
6811                return Err(invalid(
6812                    "retained canonical history is not ordered by block number".into(),
6813                ));
6814            }
6815            if block.number == previous.number {
6816                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
6817                {
6818                    "duplicate"
6819                } else {
6820                    "conflicting"
6821                };
6822                return Err(invalid(format!(
6823                    "retained canonical history contains {qualifier} identities at block {}",
6824                    block.number
6825                )));
6826            }
6827            if previous.number.checked_add(1) == Some(block.number)
6828                && block.parent_hash.is_some()
6829                && block.parent_hash != Some(previous.hash)
6830            {
6831                return Err(invalid(format!(
6832                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
6833                    block.number, block.hash, previous.number, previous.hash
6834                )));
6835            }
6836        }
6837        prior = Some(*block);
6838    }
6839    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
6840        return Err(invalid(
6841            "retained canonical history requires an authoritative coverage head".into(),
6842        ));
6843    }
6844    if let Some(head) = state.coverage_head.as_ref() {
6845        if let Some(retained) = state
6846            .retained_canonical_history
6847            .iter()
6848            .find(|entry| entry.number == head.number)
6849            && !optional_block_refs_are_compatible(Some(retained), Some(head))
6850        {
6851            return Err(invalid(format!(
6852                "coverage head {}:{:?} conflicts with retained identity {:?}",
6853                head.number, head.hash, retained
6854            )));
6855        }
6856        if state
6857            .retained_canonical_history
6858            .last()
6859            .is_some_and(|retained| retained.number > head.number)
6860        {
6861            return Err(invalid(
6862                "retained canonical history advances beyond the coverage head".into(),
6863            ));
6864        }
6865        if let Some(retained) = state.retained_canonical_history.last()
6866            && retained.number.checked_add(1) == Some(head.number)
6867            && head.parent_hash.is_some()
6868            && head.parent_hash != Some(retained.hash)
6869        {
6870            return Err(invalid(format!(
6871                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
6872                head.number, head.hash, retained.number, retained.hash
6873            )));
6874        }
6875    }
6876    if let Some(safe) = state.safe_head.as_ref() {
6877        validate_sequence_known_identity(state, safe, "safe")?;
6878        validate_sequence_head_within_coverage(state, safe, "safe")?;
6879        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
6880    }
6881    if let Some(finalized) = state.finalized_head.as_ref() {
6882        validate_sequence_known_identity(state, finalized, "finalized")?;
6883        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
6884        validate_coverage_descends_from_adjacent_head(
6885            state.coverage_head.as_ref(),
6886            finalized,
6887            "finalized",
6888        )?;
6889    }
6890    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
6891    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
6892        && (finalized.number > safe.number
6893            || (finalized.number == safe.number && finalized.hash != safe.hash))
6894    {
6895        return Err(invalid(
6896            "finalized head cannot advance beyond or conflict with safe head".into(),
6897        ));
6898    }
6899    Ok(())
6900}
6901
6902fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
6903    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
6904    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
6905    for block in blocks.iter().copied() {
6906        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
6907            && previous_height != block.number
6908        {
6909            return Err(ReactiveError::InvalidChainControl {
6910                message: format!(
6911                    "canonical hash {:?} is reused at heights {} and {}",
6912                    block.hash, previous_height, block.number
6913                ),
6914            });
6915        }
6916        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
6917            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
6918                return Err(ReactiveError::InvalidChainControl {
6919                    message: format!(
6920                        "canonical aliases at height {} carry conflicting identities or metadata",
6921                        block.number
6922                    ),
6923                });
6924            }
6925            enrich_block_ref(resolved, block);
6926        } else {
6927            resolved_by_height.insert(block.number, *block);
6928        }
6929    }
6930    for child in resolved_by_height.values() {
6931        let Some(parent_hash) = child.parent_hash else {
6932            continue;
6933        };
6934        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
6935            && parent_number.checked_add(1) != Some(child.number)
6936        {
6937            return Err(ReactiveError::InvalidChainControl {
6938                message: format!(
6939                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
6940                    child.number, child.hash, parent_hash, parent_number
6941                ),
6942            });
6943        }
6944        if let Some(parent_number) = child.number.checked_sub(1)
6945            && let Some(parent) = resolved_by_height.get(&parent_number)
6946            && parent.hash != parent_hash
6947        {
6948            return Err(ReactiveError::InvalidChainControl {
6949                message: format!(
6950                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
6951                    child.number, child.hash, parent.number, parent.hash
6952                ),
6953            });
6954        }
6955    }
6956    Ok(())
6957}
6958
6959fn validate_coverage_descends_from_adjacent_head(
6960    coverage: Option<&BlockRef>,
6961    head: &BlockRef,
6962    label: &str,
6963) -> Result<(), ReactiveError> {
6964    let Some(coverage) = coverage else {
6965        return Ok(());
6966    };
6967    if head.number.checked_add(1) == Some(coverage.number)
6968        && coverage
6969            .parent_hash
6970            .is_some_and(|parent| parent != head.hash)
6971    {
6972        return Err(ReactiveError::InvalidChainControl {
6973            message: format!(
6974                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
6975                coverage.number, coverage.hash, head.number, head.hash
6976            ),
6977        });
6978    }
6979    Ok(())
6980}
6981
6982fn validate_sequence_control(
6983    state: &CanonicalSequenceState,
6984    control: &ChainControl,
6985) -> Result<(), ReactiveError> {
6986    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
6987    match control {
6988        ChainControl::Safe(block) => {
6989            validate_sequence_known_identity(state, block, "safe")?;
6990            validate_sequence_head_within_coverage(state, block, "safe")?;
6991            if let Some(current) = state.safe_head.as_ref()
6992                && (block.number < current.number
6993                    || (block.number == current.number
6994                        && (block.hash != current.hash
6995                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
6996            {
6997                return Err(invalid(format!(
6998                    "safe head {}:{:?} conflicts with current {}:{:?}",
6999                    block.number, block.hash, current.number, current.hash
7000                )));
7001            }
7002            if let Some(finalized) = state.finalized_head.as_ref()
7003                && (block.number < finalized.number
7004                    || (block.number == finalized.number && block.hash != finalized.hash))
7005            {
7006                return Err(invalid(
7007                    "safe head cannot precede or conflict with finalized head".into(),
7008                ));
7009            }
7010            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7011        }
7012        ChainControl::Finalized(block) => {
7013            validate_sequence_known_identity(state, block, "finalized")?;
7014            validate_sequence_head_within_coverage(state, block, "finalized")?;
7015            if let Some(current) = state.finalized_head.as_ref()
7016                && (block.number < current.number
7017                    || (block.number == current.number
7018                        && (block.hash != current.hash
7019                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7020            {
7021                return Err(invalid(format!(
7022                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7023                    block.number, block.hash, current.number, current.hash
7024                )));
7025            }
7026            if let Some(safe) = state.safe_head.as_ref()
7027                && (block.number > safe.number
7028                    || (block.number == safe.number && block.hash != safe.hash))
7029            {
7030                return Err(invalid(
7031                    "finalized head cannot advance beyond or conflict with safe head".into(),
7032                ));
7033            }
7034            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7035        }
7036        ChainControl::CanonicalProgress(block)
7037        | ChainControl::Barrier {
7038            block: Some(block), ..
7039        } => {
7040            validate_sequence_known_identity(state, block, "canonical coverage")?;
7041            if let Some(current) = state.coverage_head.as_ref()
7042                && (block.number < current.number
7043                    || (block.number == current.number && block.hash != current.hash))
7044            {
7045                return Err(invalid(format!(
7046                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7047                    block.number, block.hash, current.number, current.hash
7048                )));
7049            }
7050            if let Some(current) = state.coverage_head.as_ref()
7051                && current.number.checked_add(1) == Some(block.number)
7052                && block.parent_hash.is_some()
7053                && block.parent_hash != Some(current.hash)
7054            {
7055                return Err(invalid(format!(
7056                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7057                    block.number, block.hash, current.number, current.hash
7058                )));
7059            }
7060        }
7061        ChainControl::Barrier { block: None, .. } => {}
7062        ChainControl::Reorg {
7063            common_ancestor,
7064            old_tip,
7065            new_tip,
7066        } => {
7067            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7068            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7069            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7070            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7071            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7072            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7073            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7074            if let Some(current) = state.coverage_head.as_ref()
7075                && (old_tip.number != current.number
7076                    || old_tip.hash != current.hash
7077                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7078            {
7079                return Err(invalid(format!(
7080                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7081                    old_tip.number, old_tip.hash, current.number, current.hash
7082                )));
7083            }
7084            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7085                return Err(invalid(
7086                    "reorg common ancestor cannot be above either branch tip".into(),
7087                ));
7088            }
7089            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7090            {
7091                return Err(invalid(
7092                    "reorg must replace non-empty old and new branches above the common ancestor"
7093                        .into(),
7094                ));
7095            }
7096            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7097                return Err(invalid(
7098                    "reorg old and new tips cannot have the same canonical identity".into(),
7099                ));
7100            }
7101            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7102                if common_ancestor.number.checked_add(1) == Some(tip.number)
7103                    && tip.parent_hash != Some(common_ancestor.hash)
7104                {
7105                    return Err(invalid(format!(
7106                        "reorg {label} tip does not descend from the common ancestor"
7107                    )));
7108                }
7109            }
7110            if let Some(finalized) = state.finalized_head.as_ref()
7111                && (common_ancestor.number < finalized.number
7112                    || (common_ancestor.number == finalized.number
7113                        && common_ancestor.hash != finalized.hash))
7114            {
7115                return Err(invalid(
7116                    "reorg would cross or conflict with the finalized head".into(),
7117                ));
7118            }
7119        }
7120    }
7121    Ok(())
7122}
7123
7124fn validate_sequence_known_identity(
7125    state: &CanonicalSequenceState,
7126    block: &BlockRef,
7127    label: &str,
7128) -> Result<(), ReactiveError> {
7129    validate_sequence_known_hash_height(state, block, label)?;
7130    validate_sequence_known_parent_height(state, block, label)?;
7131    let known = state
7132        .coverage_head
7133        .as_ref()
7134        .filter(|head| head.number == block.number)
7135        .or_else(|| {
7136            state
7137                .retained_canonical_history
7138                .iter()
7139                .find(|entry| entry.number == block.number)
7140        });
7141    if let Some(known) = known
7142        && !optional_block_refs_are_compatible(Some(known), Some(block))
7143    {
7144        return Err(ReactiveError::InvalidChainControl {
7145            message: format!(
7146                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7147                block.number, block.hash, known
7148            ),
7149        });
7150    }
7151    Ok(())
7152}
7153
7154fn validate_sequence_known_parent_height(
7155    state: &CanonicalSequenceState,
7156    block: &BlockRef,
7157    label: &str,
7158) -> Result<(), ReactiveError> {
7159    let Some(parent_hash) = block.parent_hash else {
7160        return Ok(());
7161    };
7162    let known_parent = state
7163        .retained_canonical_history
7164        .iter()
7165        .chain(state.coverage_head.iter())
7166        .chain(state.safe_head.iter())
7167        .chain(state.finalized_head.iter())
7168        .find(|known| known.hash == parent_hash);
7169    if let Some(parent) = known_parent
7170        && parent.number.checked_add(1) != Some(block.number)
7171    {
7172        return Err(ReactiveError::InvalidChainControl {
7173            message: format!(
7174                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7175                block.number, block.hash, parent.hash, parent.number
7176            ),
7177        });
7178    }
7179    Ok(())
7180}
7181
7182fn validate_sequence_head_within_coverage(
7183    state: &CanonicalSequenceState,
7184    block: &BlockRef,
7185    label: &str,
7186) -> Result<(), ReactiveError> {
7187    let Some(coverage) = state.coverage_head.as_ref() else {
7188        return Err(ReactiveError::InvalidChainControl {
7189            message: format!("{label} head requires an authoritative coverage head"),
7190        });
7191    };
7192    if block.number > coverage.number
7193        || (block.number == coverage.number
7194            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7195    {
7196        return Err(ReactiveError::InvalidChainControl {
7197            message: format!(
7198                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7199                block.number, block.hash, coverage.number, coverage.hash
7200            ),
7201        });
7202    }
7203    Ok(())
7204}
7205
7206fn validate_sequence_matching_metadata(
7207    state: &CanonicalSequenceState,
7208    block: &BlockRef,
7209    label: &str,
7210) -> Result<(), ReactiveError> {
7211    validate_sequence_known_hash_height(state, block, label)?;
7212    validate_sequence_known_parent_height(state, block, label)?;
7213    let known = state
7214        .coverage_head
7215        .as_ref()
7216        .filter(|head| head.number == block.number && head.hash == block.hash)
7217        .or_else(|| {
7218            state
7219                .retained_canonical_history
7220                .iter()
7221                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7222        });
7223    if let Some(known) = known
7224        && !optional_block_refs_are_compatible(Some(known), Some(block))
7225    {
7226        return Err(ReactiveError::InvalidChainControl {
7227            message: format!(
7228                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7229                block.number, block.hash, known
7230            ),
7231        });
7232    }
7233    Ok(())
7234}
7235
7236fn validate_sequence_known_hash_height(
7237    state: &CanonicalSequenceState,
7238    block: &BlockRef,
7239    label: &str,
7240) -> Result<(), ReactiveError> {
7241    let known = state
7242        .retained_canonical_history
7243        .iter()
7244        .chain(state.coverage_head.iter())
7245        .chain(state.safe_head.iter())
7246        .chain(state.finalized_head.iter())
7247        .find(|known| known.hash == block.hash);
7248    if let Some(known) = known
7249        && known.number != block.number
7250    {
7251        return Err(ReactiveError::InvalidChainControl {
7252            message: format!(
7253                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7254                block.number, block.hash, known.number
7255            ),
7256        });
7257    }
7258    Ok(())
7259}
7260
7261fn validate_reorg_ancestor_against_retained_branch(
7262    state: &CanonicalSequenceState,
7263    ancestor: &BlockRef,
7264) -> Result<(), ReactiveError> {
7265    let adjacent_number = ancestor.number.checked_add(1);
7266    for retained in state
7267        .retained_canonical_history
7268        .iter()
7269        .chain(state.coverage_head.iter())
7270        .chain(state.safe_head.iter())
7271        .chain(state.finalized_head.iter())
7272    {
7273        if Some(retained.number) == adjacent_number
7274            && retained
7275                .parent_hash
7276                .is_some_and(|parent| parent != ancestor.hash)
7277        {
7278            return Err(ReactiveError::InvalidChainControl {
7279                message: format!(
7280                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7281                    ancestor.number,
7282                    ancestor.hash,
7283                    retained.number,
7284                    retained.hash,
7285                    retained.parent_hash
7286                ),
7287            });
7288        }
7289        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7290            return Err(ReactiveError::InvalidChainControl {
7291                message: format!(
7292                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7293                    ancestor.number, ancestor.hash, retained.number, retained.hash
7294                ),
7295            });
7296        }
7297    }
7298    Ok(())
7299}
7300
7301fn validate_sequence_adjacent_parent_identity(
7302    state: &CanonicalSequenceState,
7303    block: &BlockRef,
7304    label: &str,
7305) -> Result<(), ReactiveError> {
7306    let Some(parent_hash) = block.parent_hash else {
7307        return Ok(());
7308    };
7309    let Some(parent_number) = block.number.checked_sub(1) else {
7310        return Ok(());
7311    };
7312    let known_parent = state
7313        .retained_canonical_history
7314        .iter()
7315        .chain(state.coverage_head.iter())
7316        .chain(state.safe_head.iter())
7317        .chain(state.finalized_head.iter())
7318        .find(|known| known.number == parent_number);
7319    if let Some(known_parent) = known_parent
7320        && known_parent.hash != parent_hash
7321    {
7322        return Err(ReactiveError::InvalidChainControl {
7323            message: format!(
7324                "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7325                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7326            ),
7327        });
7328    }
7329    Ok(())
7330}
7331
7332fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7333    state
7334        .coverage_head
7335        .iter()
7336        .chain(state.retained_canonical_history.iter())
7337        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7338        .any(|known| {
7339            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7340                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7341        })
7342}
7343
7344fn validate_sequence_implicit_finality<N: Network>(
7345    state: &CanonicalSequenceState,
7346    record: &ReactiveInputRecord<N>,
7347    resolved_canonical_block: Option<&BlockRef>,
7348) -> Result<(), ReactiveError> {
7349    let Some(finalized) = state.finalized_head.as_ref() else {
7350        return Ok(());
7351    };
7352    if let Some((dropped, _)) = reorg_signal_block(record) {
7353        if dropped.number <= finalized.number {
7354            return Err(ReactiveError::InvalidChainControl {
7355                message: format!(
7356                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7357                    dropped.number, dropped.hash, finalized.number, finalized.hash
7358                ),
7359            });
7360        }
7361        return Ok(());
7362    }
7363    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7364        return Ok(());
7365    };
7366    let Some(latest) = state.coverage_head.as_ref() else {
7367        return Ok(());
7368    };
7369    if (block.number == latest.number && block.hash == latest.hash)
7370        || state
7371            .retained_canonical_history
7372            .iter()
7373            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7374        || (latest.number.checked_add(1) == Some(block.number)
7375            && block.parent_hash == Some(latest.hash))
7376        || latest
7377            .number
7378            .checked_add(1)
7379            .is_some_and(|next| block.number > next)
7380    {
7381        return Ok(());
7382    }
7383    let crosses_finalized = if block.number <= finalized.number {
7384        true
7385    } else if let Some(parent_hash) = block.parent_hash {
7386        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7387            false
7388        } else if let Some(parent_index) =
7389            state.retained_canonical_history.iter().rposition(|entry| {
7390                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7391            })
7392        {
7393            state
7394                .retained_canonical_history
7395                .iter()
7396                .skip(parent_index + 1)
7397                .any(|entry| entry.number <= finalized.number)
7398        } else {
7399            true
7400        }
7401    } else {
7402        true
7403    };
7404    if crosses_finalized {
7405        return Err(ReactiveError::InvalidChainControl {
7406            message: format!(
7407                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7408                block.number, block.hash, finalized.number, finalized.hash
7409            ),
7410        });
7411    }
7412    Ok(())
7413}
7414
7415fn validate_required_reorg_anchor(
7416    required: Option<RequiredReorgAnchor>,
7417    block: &BlockRef,
7418) -> Result<(), ReactiveError> {
7419    let Some(required) = required else {
7420        return Ok(());
7421    };
7422    let ancestor_hash = required.hash();
7423    let restores_ancestor =
7424        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
7425    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
7426        && ancestor_hash.is_some()
7427        && (block.parent_hash == ancestor_hash
7428            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
7429    if restores_ancestor || replaces_removed_child {
7430        return Ok(());
7431    }
7432    Err(ReactiveError::InvalidChainControl {
7433        message: format!(
7434            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
7435            block.number, block.hash, required.number
7436        ),
7437    })
7438}
7439
7440fn validate_replacement_reorg_anchor(
7441    required: Option<RequiredReorgAnchor>,
7442    block: &BlockRef,
7443    policy: CanonicalSequenceValidationPolicy,
7444    oldest_retained: Option<u64>,
7445) -> Result<bool, CanonicalSequenceError> {
7446    let Some(required) = required else {
7447        return Ok(false);
7448    };
7449    match validate_required_reorg_anchor(Some(required), block) {
7450        Ok(()) => Ok(true),
7451        Err(error) if required.block.is_some() => Err(error.into()),
7452        Err(_) if policy.requires_complete_rollback() => {
7453            Err(CanonicalSequenceError::IncompleteRollback {
7454                common_ancestor: required.number,
7455                oldest_retained,
7456                kind: CanonicalRollbackKind::MissingReplacement,
7457            })
7458        }
7459        Err(_) => Ok(false),
7460    }
7461}
7462
7463fn apply_sequence_canonical_block(
7464    state: &mut CanonicalSequenceState,
7465    block: &BlockRef,
7466    allow_parentless_adjacent_extension: bool,
7467) -> Result<Option<SequenceRewind>, ReactiveError> {
7468    let latest = state.coverage_head;
7469    let already_known = state
7470        .retained_canonical_history
7471        .iter()
7472        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7473    let repeats_tip =
7474        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
7475    let extends_tip = latest.is_some_and(|head| {
7476        head.number.checked_add(1) == Some(block.number)
7477            && (block.parent_hash == Some(head.hash)
7478                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
7479    });
7480    let forward_gap = latest.is_some_and(|head| {
7481        head.number
7482            .checked_add(1)
7483            .is_some_and(|next| block.number > next)
7484    });
7485    let mut rewind = None;
7486
7487    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
7488        let retained_parent = block.parent_hash.and_then(|parent_hash| {
7489            state
7490                .retained_canonical_history
7491                .iter()
7492                .rposition(|entry| {
7493                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7494                })
7495                .map(|index| (index, state.retained_canonical_history[index]))
7496        });
7497        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
7498            state.finalized_head.filter(|finalized| {
7499                finalized.number.checked_add(1) == Some(block.number)
7500                    && finalized.hash == parent_hash
7501            })
7502        });
7503        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
7504            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
7505            (Some(parent), dropped)
7506        } else if let Some(finalized) = finalized_parent {
7507            let dropped = state
7508                .retained_canonical_history
7509                .iter()
7510                .position(|entry| entry.number > finalized.number)
7511                .map_or_else(Vec::new, |index| {
7512                    state.retained_canonical_history.split_off(index)
7513                });
7514            (Some(finalized), dropped)
7515        } else {
7516            // The observable runtime policy may continue after an incomplete
7517            // rollback proof so it can degrade health and repair. The metadata
7518            // validator must nevertheless avoid claiming any old prefix is an
7519            // ancestor of the arriving branch: without the exact N-1 parent,
7520            // no retained identity is authenticated.
7521            (None, std::mem::take(&mut state.retained_canonical_history))
7522        };
7523        state.coverage_head = common_ancestor;
7524        if let Some(common_ancestor) = common_ancestor {
7525            clear_sequence_heads_above(state, &common_ancestor);
7526        } else {
7527            state.safe_head = None;
7528            state.finalized_head = None;
7529        }
7530        rewind = Some(SequenceRewind {
7531            common_ancestor,
7532            dropped,
7533        });
7534    }
7535    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
7536    advance_or_enrich_coverage(&mut state.coverage_head, block);
7537    Ok(rewind)
7538}
7539
7540fn sequence_implicit_replacement_requires_history(
7541    state: &CanonicalSequenceState,
7542    block: &BlockRef,
7543    policy: CanonicalSequenceValidationPolicy,
7544) -> Result<bool, ReactiveError> {
7545    let Some(latest) = state.coverage_head else {
7546        return Ok(false);
7547    };
7548    let already_known = state
7549        .retained_canonical_history
7550        .iter()
7551        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7552    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
7553    let extends_tip = latest.number.checked_add(1) == Some(block.number)
7554        && block.parent_hash == Some(latest.hash);
7555    let forward_gap = latest
7556        .number
7557        .checked_add(1)
7558        .is_some_and(|next| block.number > next);
7559    if already_known || repeats_tip || extends_tip || forward_gap {
7560        return Ok(false);
7561    }
7562    let Some(parent_hash) = block.parent_hash else {
7563        if policy.requires_complete_rollback() {
7564            return Err(ReactiveError::InvalidChainControl {
7565                message: format!(
7566                    "implicit canonical replacement {}:{:?} must identify its parent",
7567                    block.number, block.hash
7568                ),
7569            });
7570        }
7571        return Ok(true);
7572    };
7573    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
7574        state
7575            .retained_canonical_history
7576            .iter()
7577            .chain(state.coverage_head.iter())
7578            .chain(state.safe_head.iter())
7579            .chain(state.finalized_head.iter())
7580            .find(|known| known.number == parent_number)
7581    });
7582    if let Some(known_parent) = known_adjacent_parent
7583        && known_parent.hash != parent_hash
7584        && policy.requires_complete_rollback()
7585    {
7586        return Err(ReactiveError::InvalidChainControl {
7587            message: format!(
7588                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7589                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7590            ),
7591        });
7592    }
7593    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
7594        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7595    });
7596    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
7597        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
7598    });
7599    Ok(!retained_parent && !finalized_parent)
7600}
7601
7602fn upsert_sequence_history(
7603    history: &mut Vec<BlockRef>,
7604    block: &BlockRef,
7605) -> Result<(), ReactiveError> {
7606    if let Some(existing) = history
7607        .iter_mut()
7608        .find(|entry| entry.number == block.number)
7609    {
7610        if existing.hash != block.hash {
7611            return Err(ReactiveError::InvalidChainControl {
7612                message: format!(
7613                    "canonical block {}:{:?} conflicts with retained identity {:?}",
7614                    block.number, block.hash, existing
7615                ),
7616            });
7617        }
7618        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
7619            return Err(ReactiveError::InvalidChainControl {
7620                message: format!(
7621                    "canonical block {}:{:?} carries conflicting retained metadata",
7622                    block.number, block.hash
7623                ),
7624            });
7625        }
7626        enrich_block_ref(existing, block);
7627    } else {
7628        history.push(*block);
7629        history.sort_by_key(|entry| entry.number);
7630    }
7631    Ok(())
7632}
7633
7634fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
7635    if state.safe_head.as_ref().is_some_and(|head| {
7636        head.number > ancestor.number
7637            || (head.number == ancestor.number && head.hash != ancestor.hash)
7638    }) {
7639        state.safe_head = None;
7640    }
7641    if state.finalized_head.as_ref().is_some_and(|head| {
7642        head.number > ancestor.number
7643            || (head.number == ancestor.number && head.hash != ancestor.hash)
7644    }) {
7645        state.finalized_head = None;
7646    }
7647}
7648
7649fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
7650    let split = controls
7651        .iter()
7652        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
7653        .unwrap_or(controls.len());
7654    if controls[split..]
7655        .iter()
7656        .any(|control| matches!(control, ChainControl::Reorg { .. }))
7657    {
7658        return Err(ReactiveError::InvalidChainControl {
7659            message: "reorg controls must precede records and all post-record controls in a batch"
7660                .into(),
7661        });
7662    }
7663    Ok(split)
7664}
7665
7666fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
7667    match control {
7668        ChainControl::CanonicalProgress(block)
7669        | ChainControl::Barrier {
7670            block: Some(block), ..
7671        } => Some(block),
7672        ChainControl::Reorg { .. }
7673        | ChainControl::Safe(_)
7674        | ChainControl::Finalized(_)
7675        | ChainControl::Barrier { block: None, .. } => None,
7676    }
7677}
7678
7679fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
7680    match control {
7681        ChainControl::Safe(block)
7682        | ChainControl::Finalized(block)
7683        | ChainControl::CanonicalProgress(block)
7684        | ChainControl::Barrier {
7685            block: Some(block), ..
7686        } => Some(block),
7687        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
7688    }
7689}
7690
7691fn assert_chain_control_identities(
7692    asserted_blocks: &mut HashMap<u64, BlockRef>,
7693    control: &ChainControl,
7694) -> Result<(), ReactiveError> {
7695    match control {
7696        ChainControl::Safe(block)
7697        | ChainControl::Finalized(block)
7698        | ChainControl::CanonicalProgress(block)
7699        | ChainControl::Barrier {
7700            block: Some(block), ..
7701        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
7702        ChainControl::Barrier { block: None, .. } => Ok(()),
7703        ChainControl::Reorg {
7704            common_ancestor,
7705            new_tip,
7706            ..
7707        } => {
7708            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
7709            assert_canonical_block_identity(
7710                asserted_blocks,
7711                common_ancestor,
7712                "reorg common ancestor",
7713            )?;
7714            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
7715        }
7716    }
7717}
7718
7719fn assert_canonical_block_identity(
7720    asserted_blocks: &mut HashMap<u64, BlockRef>,
7721    block: &BlockRef,
7722    label: &str,
7723) -> Result<(), ReactiveError> {
7724    for asserted in asserted_blocks.values() {
7725        if asserted.hash == block.hash && asserted.number != block.number {
7726            return Err(ReactiveError::InvalidChainControl {
7727                message: format!(
7728                    "{label} hash {:?} is already asserted at height {}, not {}",
7729                    block.hash, asserted.number, block.number
7730                ),
7731            });
7732        }
7733        if block
7734            .parent_hash
7735            .is_some_and(|parent| parent == asserted.hash)
7736            && asserted.number.checked_add(1) != Some(block.number)
7737        {
7738            return Err(ReactiveError::InvalidChainControl {
7739                message: format!(
7740                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7741                    block.number, block.hash, asserted.hash, asserted.number
7742                ),
7743            });
7744        }
7745        if asserted
7746            .parent_hash
7747            .is_some_and(|parent| parent == block.hash)
7748            && block.number.checked_add(1) != Some(asserted.number)
7749        {
7750            return Err(ReactiveError::InvalidChainControl {
7751                message: format!(
7752                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
7753                    asserted.number, asserted.hash, block.hash, block.number
7754                ),
7755            });
7756        }
7757    }
7758    if let Some(known) = asserted_blocks.get_mut(&block.number) {
7759        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
7760            return Err(ReactiveError::InvalidChainControl {
7761                message: format!(
7762                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
7763                    block.number, block.hash, known
7764                ),
7765            });
7766        }
7767        enrich_block_ref(known, block);
7768    } else {
7769        asserted_blocks.insert(block.number, *block);
7770    }
7771    Ok(())
7772}
7773
7774fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
7775    match current {
7776        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
7777            enrich_block_ref(current, incoming);
7778        }
7779        _ => *current = Some(*incoming),
7780    }
7781}
7782
7783fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
7784    match current {
7785        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
7786            enrich_block_ref(current, incoming);
7787        }
7788        Some(current) if current.number >= incoming.number => {}
7789        _ => *current = Some(*incoming),
7790    }
7791}
7792
7793fn validate_adjacent_finality(
7794    finalized: Option<&BlockRef>,
7795    safe: Option<&BlockRef>,
7796) -> Result<(), ReactiveError> {
7797    let Some((finalized, safe)) = finalized.zip(safe) else {
7798        return Ok(());
7799    };
7800    if finalized.number.checked_add(1) == Some(safe.number)
7801        && safe.parent_hash != Some(finalized.hash)
7802    {
7803        return Err(ReactiveError::InvalidChainControl {
7804            message: "adjacent safe head does not descend from finalized head".into(),
7805        });
7806    }
7807    Ok(())
7808}
7809
7810/// Fold every address a [`StateDiff`] references — genuine changes
7811/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
7812/// into `into`. Used by the per-block root gate to accumulate the batch's
7813/// decoder-touched address set: an account a decoder wrote (or tried to write) is
7814/// "covered," so a subsequent root move for it is not a coverage gap.
7815fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
7816    into.extend(diff.slots.iter().map(|change| change.address));
7817    into.extend(diff.accounts.iter().map(|change| change.address));
7818    into.extend(diff.purged.iter().map(|purge| purge.address));
7819    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
7820    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
7821    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
7822    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
7823}
7824
7825/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
7826/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
7827/// existing account-resync path (Wave 2). The id is derived from the address and
7828/// block so a repeated move on the same account/block coalesces deterministically.
7829fn root_moved_account_resync(
7830    address: Address,
7831    block: u64,
7832    fields: AccountFieldMask,
7833) -> ResyncRequest {
7834    ResyncRequest {
7835        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
7836        reason: ResyncReason::RootMoved,
7837        block: ResyncBlock::Number(block),
7838        targets: vec![ResyncTarget::Account { address, fields }],
7839        priority: ResyncPriority::Normal,
7840    }
7841}
7842
7843fn batch_preconfirmation<N: Network>(
7844    batch: &ReactiveInputBatch<N>,
7845) -> Result<Option<FlashblockRef>, ReactiveError> {
7846    let mut flashblock: Option<FlashblockRef> = None;
7847    let mut has_non_preconfirmed = false;
7848    for (index, record) in batch.records().iter().enumerate() {
7849        match &record.context.chain_status {
7850            ChainStatus::Preconfirmed {
7851                flashblock: current,
7852            } => {
7853                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
7854                    return Err(ReactiveError::InvalidInputRecord {
7855                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
7856                    });
7857                }
7858                if flashblock.as_ref().is_some_and(|known| known != current) {
7859                    return Err(ReactiveError::InvalidInputRecord {
7860                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
7861                    });
7862                }
7863                flashblock.get_or_insert_with(|| current.clone());
7864            }
7865            _ => has_non_preconfirmed = true,
7866        }
7867    }
7868    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
7869        return Err(ReactiveError::InvalidInputRecord {
7870            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
7871        });
7872    }
7873    Ok(flashblock)
7874}
7875
7876fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
7877    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
7878        return None;
7879    }
7880    if is_canonical_status(&record.context.chain_status) {
7881        return context_block_ref(&record.context);
7882    }
7883    None
7884}
7885
7886fn resolve_record_block_payload_metadata<N: Network>(
7887    record: &ReactiveInputRecord<N>,
7888    mut block: BlockRef,
7889) -> Result<BlockRef, ReactiveError> {
7890    let ReactiveInput::Log(log) = &record.input else {
7891        return Ok(block);
7892    };
7893    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
7894        return Err(ReactiveError::InvalidInputRecord {
7895            message: "log payload and canonical context carry different block identities".into(),
7896        });
7897    }
7898    if let Some(timestamp) = log.block_timestamp {
7899        if block.timestamp.is_some_and(|known| known != timestamp) {
7900            return Err(ReactiveError::InvalidInputRecord {
7901                message: "log payload and canonical context carry different block timestamps"
7902                    .into(),
7903            });
7904        }
7905        block.timestamp = Some(timestamp);
7906    }
7907    Ok(block)
7908}
7909
7910fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
7911    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
7912    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
7913        && record.context.block != Some(flashblock.block_ref())
7914    {
7915        return Err(invalid(
7916            "pre-confirmed status and context carry different partial block identities".into(),
7917        ));
7918    }
7919    let status_block = match &record.context.chain_status {
7920        ChainStatus::Included { block, .. }
7921        | ChainStatus::Safe { block }
7922        | ChainStatus::Finalized { block }
7923        | ChainStatus::Reorged {
7924            dropped_from: block,
7925        } => Some(block),
7926        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
7927        ChainStatus::Pending => None,
7928    };
7929    match (status_block, record.context.block.as_ref()) {
7930        (Some(status), Some(context)) if status == context => {}
7931        (Some(_), Some(_)) => {
7932            return Err(invalid(
7933                "chain status and context carry different block identities".into(),
7934            ));
7935        }
7936        (Some(_), None) => {
7937            return Err(invalid(
7938                "included or reorged input is missing its context block".into(),
7939            ));
7940        }
7941        (None, Some(_)) => {
7942            return Err(invalid(
7943                "pending input cannot carry a canonical context block".into(),
7944            ));
7945        }
7946        (None, None) => {}
7947    }
7948
7949    match &record.input {
7950        ReactiveInput::Log(log) => {
7951            let Some(block) = status_block else {
7952                return Err(invalid(
7953                    "log input must carry an included or reorged block identity".into(),
7954                ));
7955            };
7956            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
7957                return Err(invalid(
7958                    "removed log must carry reorged chain status".into(),
7959                ));
7960            }
7961            let block_number = log
7962                .block_number
7963                .ok_or_else(|| invalid("log is missing its block number".into()))?;
7964            let block_hash = log
7965                .block_hash
7966                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
7967            log.transaction_hash
7968                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
7969            let transaction_index = log
7970                .transaction_index
7971                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
7972            let log_index = log
7973                .log_index
7974                .ok_or_else(|| invalid("log is missing its log index".into()))?;
7975            if block_number != block.number
7976                || block_hash != block.hash
7977                || !optional_metadata_compatible(
7978                    log.block_timestamp.as_ref(),
7979                    block.timestamp.as_ref(),
7980                )
7981            {
7982                return Err(invalid(
7983                    "log payload and context carry different block identities".into(),
7984                ));
7985            }
7986            if record.context.transaction_index != Some(transaction_index)
7987                || record.context.log_index != Some(log_index)
7988            {
7989                return Err(invalid(
7990                    "log payload and context carry different transaction/log positions".into(),
7991                ));
7992            }
7993        }
7994        ReactiveInput::BlockHeader(header) => {
7995            if let Some(block) = status_block {
7996                if header.number() != block.number
7997                    || header.hash() != block.hash
7998                    || Some(header.parent_hash()) != block.parent_hash
7999                    || Some(header.timestamp()) != block.timestamp
8000                {
8001                    return Err(invalid(
8002                        "block header payload and context carry different block identities".into(),
8003                    ));
8004                }
8005            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8006                return Err(invalid("block header has an unsupported lifecycle".into()));
8007            }
8008            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8009                return Err(invalid(
8010                    "block header context cannot carry transaction/log positions".into(),
8011                ));
8012            }
8013        }
8014        ReactiveInput::FullBlock(block_response) => {
8015            let header = block_response.header();
8016            if let Some(block) = status_block {
8017                if header.number() != block.number
8018                    || header.hash() != block.hash
8019                    || Some(header.parent_hash()) != block.parent_hash
8020                    || Some(header.timestamp()) != block.timestamp
8021                {
8022                    return Err(invalid(
8023                        "full-block payload and context carry different block identities".into(),
8024                    ));
8025                }
8026            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8027                return Err(invalid("full block has an unsupported lifecycle".into()));
8028            }
8029            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8030                return Err(invalid(
8031                    "full-block context cannot carry transaction/log positions".into(),
8032                ));
8033            }
8034            if let Some(transactions) = block_response.transactions().as_transactions() {
8035                for (index, transaction) in transactions.iter().enumerate() {
8036                    if transaction
8037                        .block_hash()
8038                        .is_some_and(|hash| hash != header.hash())
8039                        || transaction
8040                            .block_number()
8041                            .is_some_and(|number| number != header.number())
8042                        || transaction
8043                            .transaction_index()
8044                            .is_some_and(|position| position != index as u64)
8045                    {
8046                        return Err(invalid(format!(
8047                            "full-block transaction {index} carries contradictory inclusion metadata"
8048                        )));
8049                    }
8050                    if transaction
8051                        .chain_id()
8052                        .zip(record.context.chain_id)
8053                        .is_some_and(|(transaction, context)| transaction != context)
8054                    {
8055                        return Err(invalid(format!(
8056                            "full-block transaction {index} carries a chain id conflicting with its context"
8057                        )));
8058                    }
8059                }
8060            }
8061        }
8062        ReactiveInput::PendingTxHash(_) => {
8063            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8064                return Err(invalid(
8065                    "pending transaction input must carry pending chain status".into(),
8066                ));
8067            }
8068            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8069                return Err(invalid(
8070                    "pending transaction context cannot carry canonical positions".into(),
8071                ));
8072            }
8073        }
8074        ReactiveInput::PendingTx(transaction) => {
8075            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8076                return Err(invalid(
8077                    "pending transaction input must carry pending chain status".into(),
8078                ));
8079            }
8080            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8081                return Err(invalid(
8082                    "pending transaction context cannot carry canonical positions".into(),
8083                ));
8084            }
8085            if transaction.block_hash().is_some()
8086                || transaction.block_number().is_some()
8087                || transaction.transaction_index().is_some()
8088            {
8089                return Err(invalid(
8090                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8091                ));
8092            }
8093            if transaction
8094                .chain_id()
8095                .zip(record.context.chain_id)
8096                .is_some_and(|(transaction, context)| transaction != context)
8097            {
8098                return Err(invalid(
8099                    "pending transaction carries a chain id conflicting with its context".into(),
8100                ));
8101            }
8102        }
8103    }
8104    Ok(())
8105}
8106
8107/// Best-effort per-block env refresh (Phase-8 step 2).
8108///
8109/// For a canonical record carrying a full header — a
8110/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8111/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8112/// `Some(result)` when a header was present (so the caller can surface a strict
8113/// validation error), and `None` for pending/reorged records or non-header
8114/// inputs, which must never drive a canonical env refresh.
8115fn advance_block_for_canonical_record<N: Network>(
8116    cache: &mut EvmCache,
8117    record: &ReactiveInputRecord<N>,
8118) -> Option<Result<(), BlockContextError>> {
8119    if !is_canonical_status(&record.context.chain_status) {
8120        return None;
8121    }
8122    match &record.input {
8123        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8124        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8125        _ => None,
8126    }
8127}
8128
8129fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8130    match &ctx.chain_status {
8131        ChainStatus::Included { block, .. }
8132        | ChainStatus::Safe { block }
8133        | ChainStatus::Finalized { block } => Some(block),
8134        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8135        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8136        ChainStatus::Pending => ctx.block.as_ref(),
8137    }
8138}
8139
8140fn reorg_signal_block<N: Network>(
8141    record: &ReactiveInputRecord<N>,
8142) -> Option<(BlockRef, ReorgReason)> {
8143    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8144        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8145    }
8146
8147    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8148        return Some((*dropped_from, ReorgReason::ReorgedInput));
8149    }
8150
8151    None
8152}
8153
8154fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8155    context_block_ref(&record.context)
8156        .cloned()
8157        .or_else(|| match &record.input {
8158            ReactiveInput::Log(log) => Some(BlockRef {
8159                number: log.block_number?,
8160                hash: log.block_hash?,
8161                parent_hash: None,
8162                timestamp: log.block_timestamp,
8163            }),
8164            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8165                number: header.number(),
8166                hash: header.hash(),
8167                parent_hash: Some(header.parent_hash()),
8168                timestamp: Some(header.timestamp()),
8169            }),
8170            ReactiveInput::FullBlock(block) => {
8171                let header = block.header();
8172                Some(BlockRef {
8173                    number: header.number(),
8174                    hash: header.hash(),
8175                    parent_hash: Some(header.parent_hash()),
8176                    timestamp: Some(header.timestamp()),
8177                })
8178            }
8179            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8180        })
8181}
8182
8183fn remove_canceled_resyncs_from_batch(
8184    resyncs: &mut Vec<ResyncRequest>,
8185    canceled: &[ResyncRequest],
8186) {
8187    if canceled.is_empty() {
8188        return;
8189    }
8190    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8191    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8192}
8193
8194fn resync_target_address(target: &ResyncTarget) -> Address {
8195    match target {
8196        ResyncTarget::StorageSlot { address, .. }
8197        | ResyncTarget::StorageSlots { address, .. }
8198        | ResyncTarget::Account { address, .. } => *address,
8199    }
8200}
8201
8202fn resync_request_targets_dropped_block(
8203    request: &ResyncRequest,
8204    dropped_blocks: &[BlockRef],
8205) -> bool {
8206    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8207        return false;
8208    };
8209    dropped_blocks
8210        .iter()
8211        .any(|block| block.hash == *hash && block.number == *number)
8212}
8213
8214fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8215    let first = report.requested.first()?.block.clone();
8216    if !report
8217        .requested
8218        .iter()
8219        .all(|request| request.block == first)
8220    {
8221        return None;
8222    }
8223
8224    let ResyncBlock::Hash { number, hash, .. } = first else {
8225        return None;
8226    };
8227
8228    Some(BlockRef {
8229        number,
8230        hash,
8231        parent_hash: None,
8232        timestamp: None,
8233    })
8234}
8235
8236fn purge_scopes_for_dropped_journals<N: Network>(
8237    dropped: &[BlockJournal<N>],
8238) -> Vec<(Address, PurgeScope)> {
8239    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8240    for entry in dropped.iter().rev() {
8241        for diff in entry.rollback_diffs.iter().rev() {
8242            merge_purge_scopes_for_diff(&mut scopes, diff);
8243        }
8244    }
8245    scopes
8246}
8247
8248fn rollback_updates_for_dropped_journals<N: Network>(
8249    dropped: &[BlockJournal<N>],
8250    purge_scopes: &[(Address, PurgeScope)],
8251) -> Vec<StateUpdate> {
8252    let purge_addresses: HashSet<_> = purge_scopes
8253        .iter()
8254        .map(|(address, _scope)| *address)
8255        .collect();
8256    let mut updates = Vec::new();
8257    for entry in dropped.iter().rev() {
8258        for diff in entry.rollback_diffs.iter().rev() {
8259            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8260        }
8261    }
8262    updates
8263}
8264
8265fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8266    for change in &diff.accounts {
8267        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8268    }
8269    for record in &diff.purged {
8270        merge_purge_scope(scopes, record.address, record.scope.clone());
8271    }
8272}
8273
8274fn push_rollback_updates_for_diff(
8275    updates: &mut Vec<StateUpdate>,
8276    diff: &StateDiff,
8277    purge_addresses: &HashSet<Address>,
8278) {
8279    for change in diff.slots.iter().rev() {
8280        if purge_addresses.contains(&change.address) {
8281            continue;
8282        }
8283        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8284    }
8285}
8286
8287fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8288    if let Some((_existing_address, existing_scope)) = scopes
8289        .iter_mut()
8290        .find(|(existing_address, _scope)| *existing_address == address)
8291    {
8292        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8293    } else {
8294        scopes.push((address, scope));
8295    }
8296}
8297
8298fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8299    match (left, right) {
8300        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8301        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8302        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8303            for slot in right {
8304                if !left.contains(&slot) {
8305                    left.push(slot);
8306                }
8307            }
8308            PurgeScope::Slots(left)
8309        }
8310    }
8311}
8312
8313#[derive(Clone, Debug)]
8314struct StorageFetchSlot {
8315    address: Address,
8316    slot: U256,
8317    origins: Vec<StorageFetchOrigin>,
8318}
8319
8320#[derive(Clone, Debug)]
8321struct StorageFetchOrigin {
8322    request_id: ResyncId,
8323    target: ResyncTarget,
8324}
8325
8326#[derive(Clone, Debug)]
8327struct StorageFetchGroup {
8328    block: ResyncBlock,
8329    slots: Vec<StorageFetchSlot>,
8330    seen: HashSet<(Address, U256)>,
8331}
8332
8333/// One account-target resync collected during request scanning, resolved through
8334/// the account proof fetcher after storage groups are processed.
8335#[derive(Clone, Debug)]
8336struct AccountResyncTarget {
8337    request_id: ResyncId,
8338    block: ResyncBlock,
8339    address: Address,
8340    fields: AccountFieldMask,
8341}
8342
8343fn resolve_trace_resyncs(
8344    cache: &EvmCache,
8345    storage_groups: &mut Vec<StorageFetchGroup>,
8346    account_targets: &mut Vec<AccountResyncTarget>,
8347    state_updates: &mut Vec<StateUpdate>,
8348) {
8349    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8350        return;
8351    };
8352
8353    let mut blocks = Vec::new();
8354    let mut seen = HashSet::new();
8355    for block in storage_groups
8356        .iter()
8357        .map(|group| group.block.clone())
8358        .chain(account_targets.iter().map(|target| target.block.clone()))
8359    {
8360        if seen.insert(block.clone()) {
8361            blocks.push(block);
8362        }
8363    }
8364
8365    let mut traces = HashMap::new();
8366    for block in blocks {
8367        match (fetcher)(resync_block_to_block_id(&block)) {
8368            Ok(diff) => {
8369                traces.insert(block, diff);
8370            }
8371            Err(error) => {
8372                tracing::debug!(
8373                    block = ?block,
8374                    error = %error,
8375                    "block trace resync source failed; falling back to point resync"
8376                );
8377            }
8378        }
8379    }
8380
8381    for group in storage_groups.iter_mut() {
8382        let Some(trace) = traces.get(&group.block) else {
8383            continue;
8384        };
8385        group.slots.retain(|slot| {
8386            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8387                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8388                return false;
8389            }
8390            cache
8391                .cached_storage_value(slot.address, slot.slot)
8392                .is_none()
8393        });
8394        group.seen = group
8395            .slots
8396            .iter()
8397            .map(|slot| (slot.address, slot.slot))
8398            .collect();
8399    }
8400    storage_groups.retain(|group| !group.slots.is_empty());
8401
8402    let mut unresolved_accounts = Vec::new();
8403    for mut account in account_targets.drain(..) {
8404        let Some(trace) = traces.get(&account.block) else {
8405            unresolved_accounts.push(account);
8406            continue;
8407        };
8408        let Some(trace_account) = trace
8409            .accounts
8410            .iter()
8411            .find(|diff| diff.address == account.address)
8412        else {
8413            unresolved_accounts.push(account);
8414            continue;
8415        };
8416
8417        let mut patch = AccountPatch::default();
8418        let mut unresolved = AccountFieldMask::default();
8419        if account.fields.balance {
8420            if let Some(balance) = trace_account.balance {
8421                patch = patch.balance(balance);
8422            } else {
8423                unresolved.balance = true;
8424            }
8425        }
8426        if account.fields.nonce {
8427            if let Some(nonce) = trace_account.nonce {
8428                patch = patch.nonce(nonce);
8429            } else {
8430                unresolved.nonce = true;
8431            }
8432        }
8433        if account.fields.code {
8434            if let Some(code) = &trace_account.code {
8435                patch = patch.code(code.clone());
8436            } else {
8437                unresolved.code = true;
8438            }
8439        }
8440
8441        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
8442            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8443        }
8444        if !account_field_mask_empty(unresolved) {
8445            account.fields = unresolved;
8446            unresolved_accounts.push(account);
8447        }
8448    }
8449    *account_targets = unresolved_accounts;
8450}
8451
8452fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
8453    trace
8454        .accounts
8455        .iter()
8456        .find(|account| account.address == address)
8457        .and_then(|account| {
8458            account
8459                .storage
8460                .iter()
8461                .find(|entry| entry.slot == slot)
8462                .map(|entry| entry.value)
8463        })
8464}
8465
8466fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
8467    !mask.balance && !mask.nonce && !mask.code
8468}
8469
8470fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
8471    let mut failed = Vec::new();
8472    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
8473    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
8474
8475    for request in requests {
8476        for target in &request.targets {
8477            match target {
8478                ResyncTarget::StorageSlot { address, slot } => {
8479                    push_storage_resync_slot(
8480                        &mut storage_groups,
8481                        &request.id,
8482                        &request.block,
8483                        *address,
8484                        *slot,
8485                    );
8486                }
8487                ResyncTarget::StorageSlots { address, slots } => {
8488                    for slot in slots {
8489                        push_storage_resync_slot(
8490                            &mut storage_groups,
8491                            &request.id,
8492                            &request.block,
8493                            *address,
8494                            *slot,
8495                        );
8496                    }
8497                }
8498                ResyncTarget::Account { address, fields } => {
8499                    account_targets.push(AccountResyncTarget {
8500                        request_id: request.id.clone(),
8501                        block: request.block.clone(),
8502                        address: *address,
8503                        fields: *fields,
8504                    });
8505                }
8506            }
8507        }
8508    }
8509
8510    let mut state_updates = Vec::new();
8511    resolve_trace_resyncs(
8512        cache,
8513        &mut storage_groups,
8514        &mut account_targets,
8515        &mut state_updates,
8516    );
8517
8518    if !storage_groups.is_empty() {
8519        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
8520            for group in storage_groups {
8521                let block = group.block.clone();
8522                let fetches: Vec<(Address, U256)> = group
8523                    .slots
8524                    .iter()
8525                    .map(|slot| (slot.address, slot.slot))
8526                    .collect();
8527                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
8528                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
8529                    .slots
8530                    .iter()
8531                    .cloned()
8532                    .map(|slot| ((slot.address, slot.slot), slot))
8533                    .collect();
8534
8535                for (address, slot, fetched) in results {
8536                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
8537                        continue;
8538                    };
8539                    match fetched {
8540                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
8541                        Err(error) => {
8542                            let message = error.to_string();
8543                            push_resync_failures(
8544                                &mut failed,
8545                                &block,
8546                                requested_slot.origins,
8547                                ResyncFailureKind::StorageFetchFailed,
8548                                message,
8549                            );
8550                        }
8551                    }
8552                }
8553
8554                for requested_slot in group.slots {
8555                    if pending
8556                        .remove(&(requested_slot.address, requested_slot.slot))
8557                        .is_some()
8558                    {
8559                        push_resync_failures(
8560                            &mut failed,
8561                            &block,
8562                            requested_slot.origins,
8563                            ResyncFailureKind::StorageFetchOmitted,
8564                            "storage batch fetcher did not return a value for slot".to_string(),
8565                        );
8566                    }
8567                }
8568            }
8569        } else {
8570            for group in storage_groups {
8571                let block = group.block.clone();
8572                for slot in group.slots {
8573                    push_resync_failures(
8574                        &mut failed,
8575                        &block,
8576                        slot.origins,
8577                        ResyncFailureKind::MissingStorageFetcher,
8578                        "storage resync requires a storage batch fetcher".to_string(),
8579                    );
8580                }
8581            }
8582        }
8583    }
8584
8585    if !account_targets.is_empty() {
8586        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
8587            // ONE seam invocation per distinct resync block (targets may pin
8588            // different blocks): eth_getProof is single-address at the RPC
8589            // level, so batching the addresses lets the fetcher fan the
8590            // requests out concurrently instead of paying one round trip per
8591            // account. Root-only probes: account fields need no storage keys.
8592            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
8593            for account in account_targets {
8594                let block_id = resync_block_to_block_id(&account.block);
8595                match groups
8596                    .iter_mut()
8597                    .find(|(group_block, _)| *group_block == block_id)
8598                {
8599                    Some((_, group)) => group.push(account),
8600                    None => groups.push((block_id, vec![account])),
8601                }
8602            }
8603            for (block_id, group) in groups {
8604                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
8605                    group
8606                        .iter()
8607                        .map(|account| (account.address, vec![]))
8608                        .collect(),
8609                    block_id,
8610                )
8611                .into_iter()
8612                .collect();
8613                for account in group {
8614                    // `get` + clone rather than `remove`: two targets for the
8615                    // same address in one group must both resolve from the
8616                    // single probe.
8617                    match probes.get(&account.address).cloned() {
8618                        Some(Ok(proof)) => {
8619                            // Build an authoritative account update from the requested
8620                            // field mask. Use the MATERIALIZING `account_upsert` so a
8621                            // resync applies even to a cold account (a partial `Account`
8622                            // patch on a cold address is silently skipped).
8623                            let mut patch = AccountPatch::default();
8624                            if account.fields.balance {
8625                                patch = patch.balance(proof.balance);
8626                            }
8627                            if account.fields.nonce {
8628                                patch = patch.nonce(proof.nonce);
8629                            }
8630                            // Note: `AccountProof` carries `code_hash`, not code bytes;
8631                            // the `eth_getProof` seam cannot supply runtime code, so a
8632                            // code-field resync is a no-op here (code freshness is
8633                            // handled by a later wave). We still materialize the account
8634                            // so requested balance/nonce fields take effect.
8635                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8636                        }
8637                        Some(Err(error)) => {
8638                            failed.push(ResyncFailure {
8639                                request_id: account.request_id,
8640                                block: account.block,
8641                                target: ResyncTarget::Account {
8642                                    address: account.address,
8643                                    fields: account.fields,
8644                                },
8645                                kind: ResyncFailureKind::AccountFetchFailed,
8646                                message: error.to_string(),
8647                            });
8648                        }
8649                        None => {
8650                            failed.push(ResyncFailure {
8651                                request_id: account.request_id,
8652                                block: account.block,
8653                                target: ResyncTarget::Account {
8654                                    address: account.address,
8655                                    fields: account.fields,
8656                                },
8657                                kind: ResyncFailureKind::AccountFetchOmitted,
8658                                message:
8659                                    "account proof fetcher did not return a result for address"
8660                                        .to_string(),
8661                            });
8662                        }
8663                    }
8664                }
8665            }
8666        } else {
8667            for account in account_targets {
8668                failed.push(ResyncFailure {
8669                    request_id: account.request_id,
8670                    block: account.block,
8671                    target: ResyncTarget::Account {
8672                        address: account.address,
8673                        fields: account.fields,
8674                    },
8675                    kind: ResyncFailureKind::MissingAccountFetcher,
8676                    message: "account resync requires an account proof fetcher".to_string(),
8677                });
8678            }
8679        }
8680    }
8681
8682    let diff = if state_updates.is_empty() {
8683        StateDiff::default()
8684    } else {
8685        cache.apply_updates(&state_updates)
8686    };
8687
8688    ResyncReport {
8689        requested: requests.to_vec(),
8690        state_updates,
8691        diff,
8692        failed,
8693    }
8694}
8695
8696fn push_resync_failures(
8697    failed: &mut Vec<ResyncFailure>,
8698    block: &ResyncBlock,
8699    origins: Vec<StorageFetchOrigin>,
8700    kind: ResyncFailureKind,
8701    message: String,
8702) {
8703    for origin in origins {
8704        failed.push(ResyncFailure {
8705            request_id: origin.request_id,
8706            block: block.clone(),
8707            target: origin.target,
8708            kind,
8709            message: message.clone(),
8710        });
8711    }
8712}
8713
8714fn push_storage_resync_slot(
8715    groups: &mut Vec<StorageFetchGroup>,
8716    request_id: &ResyncId,
8717    block: &ResyncBlock,
8718    address: Address,
8719    slot: U256,
8720) {
8721    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
8722        index
8723    } else {
8724        groups.push(StorageFetchGroup {
8725            block: block.clone(),
8726            slots: Vec::new(),
8727            seen: HashSet::new(),
8728        });
8729        groups.len() - 1
8730    };
8731
8732    let group = &mut groups[group_index];
8733    let origin = StorageFetchOrigin {
8734        request_id: request_id.clone(),
8735        target: ResyncTarget::StorageSlot { address, slot },
8736    };
8737    if group.seen.insert((address, slot)) {
8738        group.slots.push(StorageFetchSlot {
8739            address,
8740            slot,
8741            origins: vec![origin],
8742        });
8743    } else if let Some(existing) = group
8744        .slots
8745        .iter_mut()
8746        .find(|existing| existing.address == address && existing.slot == slot)
8747    {
8748        existing.origins.push(origin);
8749    }
8750}
8751
8752fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
8753    match block {
8754        ResyncBlock::Latest => BlockId::latest(),
8755        ResyncBlock::Pending => BlockId::pending(),
8756        ResyncBlock::Safe => BlockId::safe(),
8757        ResyncBlock::Finalized => BlockId::finalized(),
8758        ResyncBlock::Number(number) => BlockId::number(*number),
8759        ResyncBlock::Hash {
8760            number: _,
8761            hash,
8762            require_canonical,
8763        } => BlockId::from((*hash, Some(*require_canonical))),
8764    }
8765}
8766
8767impl<N: Network> RegisteredHandler<N> {
8768    fn matches(&self, input: &ReactiveInput<N>) -> bool {
8769        self.interests
8770            .iter()
8771            .any(|interest| interest_matches(interest, input))
8772    }
8773
8774    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
8775        self.interests.iter().find_map(|interest| match interest {
8776            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
8777                handler_id: self.id.clone(),
8778                route_key: interest.route_key(log),
8779            }),
8780            ReactiveInterest::Logs(_)
8781            | ReactiveInterest::Blocks(_)
8782            | ReactiveInterest::PendingTransactions(_) => None,
8783        })
8784    }
8785}
8786
8787fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
8788    let mut candidate = next.clone();
8789    let mut insertion_index = filters.len();
8790    let mut index = 0;
8791    while index < filters.len() {
8792        if filters[index].block_option != candidate.block_option {
8793            index += 1;
8794            continue;
8795        }
8796        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
8797            candidate = merged;
8798            insertion_index = insertion_index.min(index);
8799            filters.remove(index);
8800            index = 0;
8801        } else {
8802            index += 1;
8803        }
8804    }
8805    filters.insert(insertion_index.min(filters.len()), candidate);
8806}
8807
8808fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
8809    if filter_subsumes(left, right) {
8810        return Some(left.clone());
8811    }
8812    if filter_subsumes(right, left) {
8813        return Some(right.clone());
8814    }
8815    let differing_dimensions = usize::from(left.address != right.address)
8816        + left
8817            .topics
8818            .iter()
8819            .zip(right.topics.iter())
8820            .filter(|(left, right)| left != right)
8821            .count();
8822    if differing_dimensions != 1 {
8823        return None;
8824    }
8825
8826    let mut merged = left.clone();
8827    if merged.address != right.address {
8828        merge_filter_set(&mut merged.address, &right.address);
8829    } else {
8830        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
8831            if merged_topic != right_topic {
8832                merge_filter_set(merged_topic, right_topic);
8833                break;
8834            }
8835        }
8836    }
8837    Some(merged)
8838}
8839
8840fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
8841    filter_set_subsumes(&left.address, &right.address)
8842        && left
8843            .topics
8844            .iter()
8845            .zip(right.topics.iter())
8846            .all(|(left, right)| filter_set_subsumes(left, right))
8847}
8848
8849fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
8850    left.is_empty()
8851        || (!right.is_empty()
8852            && right
8853                .iter()
8854                .all(|value| left.iter().any(|known| known == value)))
8855}
8856
8857fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
8858    if target.is_empty() {
8859        return;
8860    }
8861    if source.is_empty() {
8862        *target = FilterSet::default();
8863        return;
8864    }
8865    for value in source.iter() {
8866        target.insert(value.clone());
8867    }
8868}
8869
8870#[derive(Clone, Debug)]
8871struct HandlerExecution {
8872    handler_id: HandlerId,
8873    quality: StateEffectQuality,
8874    tags: Vec<ReportTag>,
8875    state_updates: Vec<StateUpdate>,
8876    invalidations: Vec<InvalidationRequest>,
8877    resyncs: Vec<ResyncRequest>,
8878    speculative: Vec<SpeculativeRequest>,
8879    hook_signals: Vec<HookSignal>,
8880}
8881
8882impl HandlerExecution {
8883    fn from_outcome(
8884        handler_id: HandlerId,
8885        input_ref: InputRef,
8886        outcome: HandlerOutcome,
8887        preconfirmed: bool,
8888    ) -> Self {
8889        let mut state_updates = Vec::new();
8890        let mut invalidations = Vec::new();
8891        let mut resyncs = Vec::new();
8892        let mut speculative = Vec::new();
8893        let mut hook_signals = Vec::new();
8894
8895        for effect in outcome.effects {
8896            match effect {
8897                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
8898                ReactiveEffect::Invalidate(invalidation) => {
8899                    state_updates.push(StateUpdate::purge(
8900                        invalidation.address,
8901                        invalidation.scope.clone(),
8902                    ));
8903                    invalidations.push(invalidation);
8904                }
8905                ReactiveEffect::Resync(mut request) => {
8906                    if preconfirmed {
8907                        request.block = ResyncBlock::Pending;
8908                    }
8909                    resyncs.push(request);
8910                }
8911                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
8912                ReactiveEffect::Speculative(mut request) => {
8913                    request.input_ref = input_ref;
8914                    speculative.push(request);
8915                }
8916            }
8917        }
8918
8919        Self {
8920            handler_id,
8921            quality: outcome.quality,
8922            tags: outcome.tags,
8923            state_updates,
8924            invalidations,
8925            resyncs,
8926            speculative,
8927            hook_signals,
8928        }
8929    }
8930}
8931
8932fn dedupe_records<N: Network>(
8933    records: Vec<ReactiveInputRecord<N>>,
8934) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
8935    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
8936    let mut deduped = Vec::with_capacity(records.len());
8937    for record in records {
8938        let identity = record.validated_identity()?;
8939        if !record.is_payload_deduplicable() {
8940            deduped.push(record);
8941            continue;
8942        }
8943        if let Some(index) = positions.get(&identity).copied() {
8944            let merged = deduped[index].merge_compatible_duplicate(&record)?;
8945            debug_assert!(merged, "same indexed identity is deduplicable");
8946        } else {
8947            positions.insert(identity, deduped.len());
8948            deduped.push(record);
8949        }
8950    }
8951    Ok(deduped)
8952}
8953
8954fn dedupe_scoped_records<N: Network>(
8955    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
8956) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
8957    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
8958    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
8959        Vec::with_capacity(records.len());
8960    for (record, audience, delivery_scope) in records {
8961        let identity = record.validated_identity()?;
8962        if !record.is_payload_deduplicable() {
8963            deduped.push((record, audience, delivery_scope));
8964            continue;
8965        }
8966        if let Some(index) = positions.get(&identity).copied() {
8967            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
8968            debug_assert!(merged, "same indexed identity is deduplicable");
8969            merge_delivery_audience(&mut deduped[index].1, audience);
8970            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
8971        } else {
8972            positions.insert(identity, deduped.len());
8973            deduped.push((record, audience, delivery_scope));
8974        }
8975    }
8976    Ok(deduped)
8977}
8978
8979fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
8980    *into = match (*into, incoming) {
8981        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
8982        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
8983            DeliveryScope::CanonicalProgress
8984        }
8985        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
8986        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
8987        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
8988        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
8989    };
8990}
8991
8992fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
8993    match (&mut *into, incoming) {
8994        (DeliveryAudience::All, _) => {}
8995        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
8996        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
8997            for owner in incoming {
8998                if !current.contains(&owner) {
8999                    current.push(owner);
9000                }
9001            }
9002        }
9003        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9004            current.retain(|owner| incoming.contains(owner));
9005        }
9006        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9007            excluded.retain(|owner| !included.contains(owner));
9008        }
9009        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9010            let DeliveryAudience::Owners(included) = current else {
9011                unreachable!("match arm restricts the audience variant")
9012            };
9013            excluded.retain(|owner| !included.contains(owner));
9014            *current = DeliveryAudience::AllExcept(excluded);
9015        }
9016    }
9017}
9018
9019fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9020    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9021        records.into_iter().enumerate().collect();
9022    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9023    indexed.into_iter().map(|(_, record)| record).collect()
9024}
9025
9026fn sort_scoped_records<N: Network>(
9027    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9028) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9029    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9030    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9031    indexed
9032        .into_iter()
9033        .map(|(_, scoped_record)| scoped_record)
9034        .collect()
9035}
9036
9037fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9038    if let Some((block, _)) = reorg_signal_block(record) {
9039        return RecordSortKey {
9040            class: 0,
9041            block_number: block.number,
9042            record_class: 0,
9043            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9044            log_index: record.context.log_index.unwrap_or(u64::MAX),
9045            original_index: index,
9046        };
9047    }
9048    if is_canonical_status(&record.context.chain_status)
9049        && let Some(block) = record.context.block.as_ref()
9050    {
9051        let (record_class, transaction_index, log_index) = match &record.input {
9052            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9053            ReactiveInput::Log(log) if !log.removed => (
9054                1,
9055                log.transaction_index
9056                    .or(record.context.transaction_index)
9057                    .unwrap_or(u64::MAX),
9058                log.log_index
9059                    .or(record.context.log_index)
9060                    .unwrap_or(u64::MAX),
9061            ),
9062            ReactiveInput::Log(_)
9063            | ReactiveInput::PendingTxHash(_)
9064            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9065        };
9066        return RecordSortKey {
9067            class: 1,
9068            block_number: block.number,
9069            record_class,
9070            transaction_index,
9071            log_index,
9072            original_index: index,
9073        };
9074    }
9075
9076    RecordSortKey {
9077        class: 2,
9078        block_number: 0,
9079        record_class: 0,
9080        transaction_index: 0,
9081        log_index: 0,
9082        original_index: index,
9083    }
9084}
9085
9086#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9087struct RecordSortKey {
9088    class: u8,
9089    block_number: u64,
9090    record_class: u8,
9091    transaction_index: u64,
9092    log_index: u64,
9093    original_index: usize,
9094}
9095
9096fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9097    match (interest, input) {
9098        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9099        (
9100            ReactiveInterest::Blocks(BlockInterest {
9101                mode: BlockInterestMode::Header,
9102            }),
9103            ReactiveInput::BlockHeader(_),
9104        ) => true,
9105        (
9106            ReactiveInterest::Blocks(BlockInterest {
9107                mode: BlockInterestMode::FullBlock,
9108            }),
9109            ReactiveInput::FullBlock(_),
9110        ) => true,
9111        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9112            interest.matches_hash_only()
9113        }
9114        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9115            interest.matches_tx(tx)
9116        }
9117        _ => false,
9118    }
9119}
9120
9121fn validate_effects(
9122    input_ref: InputRef,
9123    ctx: &ReactiveContext,
9124    handler_id: &HandlerId,
9125    effects: &[ReactiveEffect],
9126) -> Result<(), ReactiveError> {
9127    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9128        || matches!(input_ref, InputRef::PendingTx { .. });
9129    if !pending {
9130        return Ok(());
9131    }
9132
9133    for effect in effects {
9134        let effect_kind = match effect {
9135            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9136            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9137            ReactiveEffect::Resync(_) => Some("resync"),
9138            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9139        };
9140        if let Some(effect_kind) = effect_kind {
9141            return Err(ReactiveError::InvalidPendingEffect {
9142                input_ref: Box::new(input_ref),
9143                handler_id: handler_id.clone(),
9144                effect_kind,
9145            });
9146        }
9147    }
9148    Ok(())
9149}
9150
9151fn detect_conflicts(
9152    input_ref: InputRef,
9153    executions: &[HandlerExecution],
9154) -> Result<(), ReactiveError> {
9155    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9156    for execution in executions {
9157        for update in &execution.state_updates {
9158            for (target, value) in absolute_writes(update) {
9159                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9160                    if previous_value != &value {
9161                        return Err(ReactiveError::ConflictingEffects {
9162                            input_ref: Box::new(input_ref),
9163                            target: Box::new(target),
9164                            first: previous_handler.clone(),
9165                            second: execution.handler_id.clone(),
9166                        });
9167                    }
9168                } else {
9169                    writes.insert(target, (value, execution.handler_id.clone()));
9170                }
9171            }
9172        }
9173    }
9174    Ok(())
9175}
9176
9177fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9178    match update {
9179        StateUpdate::Slot {
9180            address,
9181            slot,
9182            value,
9183        } => vec![(
9184            EffectTarget::StorageSlot {
9185                address: *address,
9186                slot: *slot,
9187            },
9188            AbsoluteValue::U256(*value),
9189        )],
9190        StateUpdate::SlotMasked {
9191            address,
9192            slot,
9193            mask,
9194            value,
9195        } => vec![(
9196            EffectTarget::MaskedStorageSlot {
9197                address: *address,
9198                slot: *slot,
9199                mask: *mask,
9200            },
9201            AbsoluteValue::U256(*value),
9202        )],
9203        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9204            account_patch_writes(*address, patch)
9205        }
9206        StateUpdate::SlotDelta { .. }
9207        | StateUpdate::BalanceDelta { .. }
9208        | StateUpdate::Purge { .. } => Vec::new(),
9209    }
9210}
9211
9212fn account_patch_writes(
9213    address: Address,
9214    patch: &AccountPatch,
9215) -> Vec<(EffectTarget, AbsoluteValue)> {
9216    let mut writes = Vec::new();
9217    if let Some(balance) = patch.balance {
9218        writes.push((
9219            EffectTarget::AccountBalance { address },
9220            AbsoluteValue::U256(balance),
9221        ));
9222    }
9223    if let Some(nonce) = patch.nonce {
9224        writes.push((
9225            EffectTarget::AccountNonce { address },
9226            AbsoluteValue::U64(nonce),
9227        ));
9228    }
9229    if let Some(code) = &patch.code {
9230        writes.push((
9231            EffectTarget::AccountCode { address },
9232            AbsoluteValue::Bytes(code.clone()),
9233        ));
9234    }
9235    writes
9236}
9237
9238fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9239    match input {
9240        ReactiveInput::Log(log) => InputRef::Log {
9241            chain_id: ctx.chain_id,
9242            block_hash: log
9243                .block_hash
9244                .or(ctx.block.as_ref().map(|block| block.hash))
9245                .unwrap_or_default(),
9246            transaction_hash: log.transaction_hash.unwrap_or_default(),
9247            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9248        },
9249        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9250            chain_id: ctx.chain_id,
9251            hash: *hash,
9252        },
9253        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9254            chain_id: ctx.chain_id,
9255            hash: tx.tx_hash(),
9256        },
9257        ReactiveInput::BlockHeader(header) => InputRef::Block {
9258            chain_id: ctx.chain_id,
9259            hash: header.hash(),
9260            number: header.number(),
9261        },
9262        ReactiveInput::FullBlock(block) => {
9263            let header = block.header();
9264            InputRef::Block {
9265                chain_id: ctx.chain_id,
9266                hash: header.hash(),
9267                number: header.number(),
9268            }
9269        }
9270    }
9271}
9272
9273fn is_canonical_status(status: &ChainStatus) -> bool {
9274    matches!(
9275        status,
9276        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9277    )
9278}
9279
9280/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9281pub struct EventDecoderHandler {
9282    id: HandlerId,
9283    decoder: Arc<dyn EventDecoder>,
9284    interest: LogInterest,
9285}
9286
9287impl EventDecoderHandler {
9288    /// Create an adapter from a decoder and log interest.
9289    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9290        Self {
9291            id,
9292            decoder,
9293            interest,
9294        }
9295    }
9296}
9297
9298impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9299    fn id(&self) -> HandlerId {
9300        self.id.clone()
9301    }
9302
9303    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9304        vec![ReactiveInterest::Logs(self.interest.clone())]
9305    }
9306
9307    fn handle(
9308        &self,
9309        _ctx: &ReactiveContext,
9310        input: &ReactiveInput<N>,
9311        state: &dyn StateView,
9312    ) -> Result<HandlerOutcome, HandlerError> {
9313        let ReactiveInput::Log(log) = input else {
9314            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9315        };
9316
9317        Ok(HandlerOutcome {
9318            effects: self
9319                .decoder
9320                .decode(&log.inner, state)
9321                .into_iter()
9322                .map(ReactiveEffect::StateUpdate)
9323                .collect(),
9324            quality: StateEffectQuality::ExactFromInput,
9325            tags: Vec::new(),
9326        })
9327    }
9328}
9329
9330/// One independently negotiable event-subscriber behavior.
9331#[derive(
9332    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9333)]
9334#[non_exhaustive]
9335pub enum SubscriberCapability {
9336    /// Emit EVM logs.
9337    Logs,
9338    /// Emit block headers.
9339    BlockHeaders,
9340    /// Emit full blocks with transaction bodies.
9341    FullBlocks,
9342    /// Emit pending transaction hashes.
9343    PendingTransactionHashes,
9344    /// Emit hydrated pending transactions.
9345    PendingTransactions,
9346    /// Fetch historical data from a caller-selected anchor.
9347    HistoricalBackfill,
9348    /// Follow live chain data.
9349    Live,
9350    /// Recover the complete committed consumer position after reconnect or
9351    /// restart, including any unacknowledged delivery.
9352    ///
9353    /// An implementation may satisfy this with native stream replay or with a
9354    /// durable cursor plus deterministic historical reconciliation of an
9355    /// ephemeral live child. The end-to-end subscriber must still prove there
9356    /// is no gap between the restored position and resumed live delivery. If an
9357    /// old delivery token is emitted again, that token must identify the same
9358    /// immutable delivery and pass the engine's witness check.
9359    DurableReplay,
9360    /// Preserve logical handler ownership on delivered batches.
9361    OwnerScopedDelivery,
9362    /// Add and remove interests without replacing the complete session.
9363    DynamicInterests,
9364    /// Emit explicit canonical branch transitions.
9365    ExplicitReorgs,
9366    /// Emit safe and finalized head updates.
9367    FinalityUpdates,
9368    /// Emit ordered synchronization or source-cutover barriers.
9369    Barriers,
9370    /// Emit sequencer pre-confirmations into a disposable state overlay.
9371    Preconfirmations,
9372}
9373
9374/// Capability set advertised by an [`EventSubscriber`].
9375///
9376/// The default is deliberately empty: callers can safely reject a topology
9377/// when an older or minimal implementation has not opted into a required
9378/// behavior.
9379#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9380pub struct SubscriberCapabilities {
9381    supported: BTreeSet<SubscriberCapability>,
9382}
9383
9384impl SubscriberCapabilities {
9385    /// Construct a capability set from supported behaviors.
9386    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9387        Self {
9388            supported: capabilities.into_iter().collect(),
9389        }
9390    }
9391
9392    /// Test one independently negotiable behavior.
9393    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9394        self.supported.contains(&capability)
9395    }
9396
9397    /// Iterate supported behaviors in stable order.
9398    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
9399        self.supported.iter().copied()
9400    }
9401
9402    /// Whether the subscriber follows live chain data.
9403    pub fn supports_live(&self) -> bool {
9404        self.supports(SubscriberCapability::Live)
9405    }
9406
9407    /// Whether the subscriber can durably recover its committed position and
9408    /// any unacknowledged delivery without an event gap.
9409    pub fn supports_durable_replay(&self) -> bool {
9410        self.supports(SubscriberCapability::DurableReplay)
9411    }
9412
9413    /// Whether the subscriber emits explicit branch transitions.
9414    pub fn supports_explicit_reorgs(&self) -> bool {
9415        self.supports(SubscriberCapability::ExplicitReorgs)
9416    }
9417}
9418
9419impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
9420    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
9421        Self::new(iter)
9422    }
9423}
9424
9425/// Provider-agnostic subscriber interface.
9426pub trait EventSubscriber<N: Network = Ethereum>: Send {
9427    /// Chain identity attached to emitted records, when it has been resolved.
9428    ///
9429    /// Remote and provider-backed subscribers should cache one authoritative
9430    /// identity before exposing input. Returning `None` is reserved for
9431    /// synthetic or genuinely chain-agnostic subscribers; composite sources
9432    /// can use this hook to reject accidentally mixed networks.
9433    fn chain_id(&self) -> Option<u64> {
9434        None
9435    }
9436
9437    /// Behaviors this subscriber can uphold for topology validation.
9438    fn capabilities(&self) -> SubscriberCapabilities {
9439        SubscriberCapabilities::default()
9440    }
9441
9442    /// Replace all interests registered with the subscriber.
9443    ///
9444    /// Implementations may use this as a full setup/reset operation. The
9445    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
9446    /// delivery/dedupe bookkeeping when this method is called.
9447    ///
9448    /// The returned operation must complete only after the replacement has
9449    /// committed to the subscriber's desired state. Remote implementations can
9450    /// use this asynchronous boundary to wait for an authoritative service-side
9451    /// acknowledgement before returning `Ok(())`. On error, or when the future
9452    /// is dropped before completion, the previously committed desired state
9453    /// must remain authoritative (or be reconciled before later delivery can
9454    /// expose the uncommitted change) so callers can safely retry.
9455    ///
9456    /// # Errors
9457    ///
9458    /// The returned operation reports [`SubscriberError`] when the replacement
9459    /// cannot be validated or committed by the underlying source.
9460    fn register_interests(
9461        &mut self,
9462        interests: &[ReactiveInterest<N>],
9463    ) -> SubscriberOperation<'_, ()>;
9464
9465    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
9466    ///
9467    /// The returned future must be cancellation-safe: dropping it while pending
9468    /// must not discard a complete input that a later call could otherwise
9469    /// deliver. Composite subscribers use this property to race historical and
9470    /// live sources without dedicating a task to each transport.
9471    ///
9472    /// # Errors
9473    ///
9474    /// The returned future reports [`SubscriberError`] for transport,
9475    /// continuity, decoding, or source-resource failures.
9476    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
9477
9478    /// Restore the subscriber's committed position before polling resumes.
9479    ///
9480    /// The engine invokes this synchronously from
9481    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
9482    /// recovery state and before publishing that state as resumed. Implementations
9483    /// should validate that provider/service cursors cannot regress and seed any
9484    /// source epoch or overlap history required for safe replay. A composite may
9485    /// rebuild an ephemeral live child from `coverage_head` plus historical
9486    /// reconciliation rather than require that child to replay bytes itself, but
9487    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
9488    /// complete restore closes that cutover gap before exposing live input. On
9489    /// error, either
9490    /// the prior position must remain authoritative, or the subscriber may retain
9491    /// this *exact* restore as pending intent; in the latter case it must block
9492    /// delivery and reject conflicting restores until retry/reconciliation commits
9493    /// the same position. This permits synchronous adapters over durable remote
9494    /// state without exposing a half-restored stream.
9495    ///
9496    /// # Errors
9497    ///
9498    /// Returns [`SubscriberError`] when the position is invalid, regresses or
9499    /// conflicts with committed source state, or cannot be restored durably.
9500    fn restore_position(
9501        &mut self,
9502        _position: &SubscriberResumePosition,
9503    ) -> Result<(), SubscriberError> {
9504        Ok(())
9505    }
9506
9507    /// Commit a subscriber-owned delivery token after runtime ingestion.
9508    ///
9509    /// Ephemeral subscribers can rely on this no-op default. Durable remote
9510    /// subscribers should make acknowledgement idempotent because cancellation
9511    /// or transport failure can cause a successfully ingested batch to replay.
9512    /// Re-emitting a token must reproduce the same immutable records, routing,
9513    /// chain controls, chain identity, and provider checkpoint; the checkpointed
9514    /// engine verifies its persisted delivery witness before skipping ingestion.
9515    ///
9516    /// # Errors
9517    ///
9518    /// The returned operation reports [`SubscriberError`] when the delivery
9519    /// token cannot be committed idempotently by the source.
9520    fn acknowledge_delivery(
9521        &mut self,
9522        _token: SubscriberDeliveryToken,
9523    ) -> SubscriberOperation<'_, ()> {
9524        Box::pin(async { Ok(()) })
9525    }
9526}
9527
9528/// Boxed, sendable future returned by subscriber lifecycle operations.
9529///
9530/// The output is generic so the same type can represent registration, removal,
9531/// and future acknowledgement values without requiring an async-trait helper.
9532pub type SubscriberOperation<'a, T> =
9533    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
9534
9535/// Boxed future returned by [`EventSubscriber::next_batch`].
9536pub type SubscriberNextBatch<'a, N> = Pin<
9537    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
9538>;
9539
9540/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
9541pub type SubscriberNextScopedBatch<'a, N> = Pin<
9542    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
9543>;
9544
9545/// Subscriber mode requested for the Alloy subscriber.
9546#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9547pub enum SubscriberMode {
9548    /// Prefer the default compiled transport.
9549    ///
9550    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
9551    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
9552    /// the opt-in `reactive-polling` feature is enabled.
9553    #[default]
9554    Auto,
9555    /// Use provider pubsub streams.
9556    PubSub,
9557    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
9558    Polling,
9559}
9560
9561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9562enum FlashblocksAdapter {
9563    BaseNative,
9564    OpPending,
9565}
9566
9567fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
9568    match chain_id {
9569        8_453 | 84_532 => Some(FlashblocksAdapter::BaseNative),
9570        10 | 11_155_420 => Some(FlashblocksAdapter::OpPending),
9571        _ => None,
9572    }
9573}
9574
9575/// Subscriber configuration.
9576#[derive(Clone, Debug, PartialEq, Eq)]
9577pub struct SubscriberConfig {
9578    /// Flashblocks delivery policy. Provider support itself is configured by
9579    /// the transport's single `flashblocks` endpoint flag.
9580    pub preconfirmations: PreconfirmationMode,
9581    /// OP pending-state sampling cadence. Base uses native `newFlashblocks`
9582    /// plus `pendingLogs` subscriptions instead.
9583    pub flashblock_poll_interval: Duration,
9584    /// Hydrate pending transaction hashes into full bodies when possible.
9585    pub hydrate_pending_transactions: bool,
9586    /// Verify each canonical log's block identity through RPC and enrich its
9587    /// context with the exact parent hash before delivery.
9588    ///
9589    /// Enable this when a strict coordinator (such as a hybrid historical/live
9590    /// source) must prove canonical ancestry from log-only pubsub events.
9591    /// Verification is cached per block, so the provider is queried at most
9592    /// once for each distinct canonical block retained in the dedupe window.
9593    /// For high-volume pubsub filters, configure
9594    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
9595    /// provider so verification responses cannot be starved by notifications.
9596    pub verify_log_block_context: bool,
9597    /// Maximum records to emit per batch.
9598    pub max_batch_size: usize,
9599    /// Maximum distinct contract addresses placed in one provider-side log
9600    /// subscription. Compatible logical owner filters are fanned into address
9601    /// supersets up to this limit; exact owner routing still happens locally.
9602    pub max_log_addresses_per_subscription: usize,
9603    /// Maximum records retained across the delivery queue and hidden
9604    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
9605    /// closed until a full interest reset, because dropping an event would
9606    /// create an unknowable continuity gap.
9607    pub max_pending_records: usize,
9608    /// Maximum lazy owner-backfill requests retained at once.
9609    pub max_pending_backfills: usize,
9610    /// Maximum approximate encoded bytes accepted from one historical log
9611    /// response (fixed log identity fields, topics, and data).
9612    pub max_backfill_log_bytes: usize,
9613    /// Maximum provider log requests concurrently in flight during bulk owner
9614    /// reconciliation.
9615    pub max_reconcile_requests_in_flight: usize,
9616    /// Reconnect policy for WebSocket/pubsub streams.
9617    pub reconnect: SubscriberReconnectConfig,
9618}
9619
9620impl Default for SubscriberConfig {
9621    fn default() -> Self {
9622        Self {
9623            preconfirmations: PreconfirmationMode::Disabled,
9624            flashblock_poll_interval: Duration::from_millis(100),
9625            hydrate_pending_transactions: false,
9626            verify_log_block_context: false,
9627            max_batch_size: 1024,
9628            max_log_addresses_per_subscription: 1024,
9629            max_pending_records: 16_384,
9630            max_pending_backfills: 4_096,
9631            max_backfill_log_bytes: 64 * 1024 * 1024,
9632            max_reconcile_requests_in_flight: 8,
9633            reconnect: SubscriberReconnectConfig::default(),
9634        }
9635    }
9636}
9637
9638/// WebSocket/pubsub reconnect policy.
9639///
9640/// Reconnects are applied after an established subscription stream terminates.
9641/// Initial subscription failures are still returned immediately so deployment
9642/// mistakes, unsupported transports, and bad endpoints fail fast.
9643#[derive(Clone, Debug, PartialEq, Eq)]
9644pub struct SubscriberReconnectConfig {
9645    /// Whether pubsub streams should be recreated after termination.
9646    pub enabled: bool,
9647    /// Delay before the first reconnect attempt.
9648    pub initial_delay: Duration,
9649    /// Delay before the second reconnect attempt. Later retries double this
9650    /// delay up to [`Self::max_delay`].
9651    pub retry_delay: Duration,
9652    /// Maximum delay between reconnect attempts.
9653    pub max_delay: Duration,
9654    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
9655    pub max_attempts: Option<usize>,
9656    /// Number of recently emitted canonical input refs remembered to suppress
9657    /// duplicates across reconnect backfill and subscription replay.
9658    pub dedupe_window: usize,
9659}
9660
9661impl Default for SubscriberReconnectConfig {
9662    fn default() -> Self {
9663        Self {
9664            enabled: true,
9665            initial_delay: Duration::ZERO,
9666            retry_delay: Duration::from_millis(250),
9667            max_delay: Duration::from_secs(30),
9668            max_attempts: Some(3),
9669            dedupe_window: 4096,
9670        }
9671    }
9672}
9673
9674/// Historical log backfill requested when adding subscriber interests.
9675///
9676/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
9677/// pending-transaction interests are live-only. `AlloySubscriber` emits records
9678/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
9679/// owner registration adopts/subscribes the desired live filter first, then
9680/// reconciles history behind that live fence; startup/global replacement commits
9681/// topology and historical work as one desired-state transaction. A drained
9682/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
9683/// when the window held no logs), so the newly added filter gets the same
9684/// reconnect/catch-up protection an established one has.
9685#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9686pub struct SubscriberBackfill {
9687    from_block: u64,
9688    to_block: Option<u64>,
9689    retained_anchor: Option<BlockRef>,
9690}
9691
9692impl SubscriberBackfill {
9693    /// Backfill an inclusive block range.
9694    pub fn range(from_block: u64, to_block: u64) -> Self {
9695        Self {
9696            from_block,
9697            to_block: Some(to_block),
9698            retained_anchor: None,
9699        }
9700    }
9701
9702    /// Backfill from `from_block` through the provider's latest block.
9703    pub fn from_block(from_block: u64) -> Self {
9704        Self {
9705            from_block,
9706            to_block: None,
9707            retained_anchor: None,
9708        }
9709    }
9710
9711    /// Backfill inclusively from an exact retained canonical block.
9712    ///
9713    /// The Alloy subscriber verifies this number/hash against its provider
9714    /// before accepting any lazy catch-up response. Engine-managed mid-stream
9715    /// registration uses this form so owner replay cannot silently cross a
9716    /// reorged discovery boundary.
9717    pub fn from_canonical_block(block: BlockRef) -> Self {
9718        Self {
9719            from_block: block.number,
9720            to_block: None,
9721            retained_anchor: Some(block),
9722        }
9723    }
9724
9725    /// Backfill inclusively from an exact canonical block through an inclusive
9726    /// upper bound.
9727    ///
9728    /// # Errors
9729    ///
9730    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
9731    /// retained anchor.
9732    pub fn from_canonical_block_through(
9733        block: BlockRef,
9734        to_block: u64,
9735    ) -> Result<Self, SubscriberError> {
9736        if to_block < block.number {
9737            return Err(SubscriberError::InvalidConfig(
9738                "inclusive backfill upper bound precedes its retained anchor",
9739            ));
9740        }
9741        Ok(Self {
9742            from_block: block.number,
9743            to_block: Some(to_block),
9744            retained_anchor: Some(block),
9745        })
9746    }
9747
9748    /// Backfill strictly after an exact canonical state baseline.
9749    ///
9750    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
9751    /// a restored cache already embodies every effect through `block`, so
9752    /// replaying that block would apply it twice. The retained block is still
9753    /// carried so the subscriber can prove that its provider is on the same
9754    /// canonical branch before accepting any post-baseline history.
9755    ///
9756    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
9757    /// exclusive range into an inclusive replay of the baseline block.
9758    ///
9759    /// # Errors
9760    ///
9761    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
9762    /// `u64::MAX` and therefore has no following block.
9763    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
9764        Self::after_canonical_block_inner(block, None)
9765    }
9766
9767    /// Backfill strictly after an exact canonical baseline through an
9768    /// inclusive upper bound.
9769    ///
9770    /// `to_block == block.number` represents a deliberately empty certified
9771    /// interval. Bounds before the retained baseline are rejected.
9772    ///
9773    /// # Errors
9774    ///
9775    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
9776    /// baseline, or when a non-empty exclusive range would have to begin after
9777    /// block `u64::MAX`.
9778    pub fn after_canonical_block_through(
9779        block: BlockRef,
9780        to_block: u64,
9781    ) -> Result<Self, SubscriberError> {
9782        if to_block < block.number {
9783            return Err(SubscriberError::InvalidConfig(
9784                "exclusive backfill upper bound precedes its retained baseline",
9785            ));
9786        }
9787        Self::after_canonical_block_inner(block, Some(to_block))
9788    }
9789
9790    fn after_canonical_block_inner(
9791        block: BlockRef,
9792        to_block: Option<u64>,
9793    ) -> Result<Self, SubscriberError> {
9794        let from_block = block
9795            .number
9796            .checked_add(1)
9797            .ok_or(SubscriberError::InvalidConfig(
9798                "cannot construct an exclusive backfill after block u64::MAX",
9799            ))?;
9800        Ok(Self {
9801            from_block,
9802            to_block,
9803            retained_anchor: Some(block),
9804        })
9805    }
9806
9807    /// First block included in the backfill.
9808    pub fn start_block(&self) -> u64 {
9809        self.from_block
9810    }
9811
9812    /// Last block included in the backfill, or `None` for provider latest.
9813    pub fn end_block(&self) -> Option<u64> {
9814        self.to_block
9815    }
9816
9817    /// Exact retained start-block identity, when supplied.
9818    pub fn retained_anchor(&self) -> Option<&BlockRef> {
9819        self.retained_anchor.as_ref()
9820    }
9821}
9822
9823/// Opaque generation for one transaction-aware subscriber interest owner.
9824///
9825/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
9826/// reused, including after an aborted stage or a full interest replacement.
9827/// Lifecycle operations require the complete token so a delayed command for an
9828/// older registration cannot affect a replacement using the same [`HandlerId`].
9829#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9830pub struct SubscriberOwnerEpoch {
9831    owner: HandlerId,
9832    sequence: u64,
9833}
9834
9835/// Delivery audience retained with a subscriber input record.
9836///
9837/// Canonical inputs are forwarded once to the runtime actor and may also name
9838/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
9839/// overlap records that must never be routed through existing canonical
9840/// handlers.
9841#[derive(Clone, Debug, PartialEq, Eq)]
9842#[non_exhaustive]
9843pub enum SubscriberInputScope {
9844    /// One canonical input plus any staged owners that matched at enqueue time.
9845    Canonical {
9846        /// Staged owner epochs that require a buffered copy.
9847        owners: Vec<SubscriberOwnerEpoch>,
9848    },
9849    /// Canonical input whose owner catch-up already delivered selected handler
9850    /// owners. The residual canonical copy must exclude those handlers while
9851    /// remaining authoritative for global chain progress.
9852    CanonicalResidual {
9853        /// Staged epoch owners that still require a buffered copy.
9854        owners: Vec<SubscriberOwnerEpoch>,
9855        /// Active compatibility owners already served by owner catch-up.
9856        excluded: Vec<HandlerId>,
9857    },
9858    /// Input delivered only to the listed staged owners.
9859    OwnerOnly {
9860        /// Exact staged owner epochs receiving the input.
9861        owners: Vec<SubscriberOwnerEpoch>,
9862    },
9863    /// Compatibility owner-only delivery keyed by stable handler id.
9864    OwnerOnlyHandlers {
9865        /// Exact active handlers receiving the catch-up input.
9866        owners: Vec<HandlerId>,
9867    },
9868    /// Flashblock input routed through ordinary matching handlers but applied
9869    /// only to the speculative overlay.
9870    Preconfirmed,
9871}
9872
9873impl SubscriberInputScope {
9874    /// Exact staged owner epochs attached to this input.
9875    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
9876        match self {
9877            Self::Canonical { owners }
9878            | Self::CanonicalResidual { owners, .. }
9879            | Self::OwnerOnly { owners } => owners,
9880            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
9881        }
9882    }
9883
9884    /// Whether this input must be forwarded once through canonical routing.
9885    pub const fn is_canonical(&self) -> bool {
9886        matches!(
9887            self,
9888            Self::Canonical { .. } | Self::CanonicalResidual { .. }
9889        )
9890    }
9891
9892    /// Whether this input belongs only to the disposable preconfirmed overlay.
9893    pub const fn is_preconfirmed(&self) -> bool {
9894        matches!(self, Self::Preconfirmed)
9895    }
9896}
9897
9898/// Reactive input together with its canonical/owner-scoped delivery audience.
9899#[derive(Clone, Debug)]
9900pub struct SubscriberInputRecord<N: Network = Ethereum> {
9901    record: ReactiveInputRecord<N>,
9902    scope: SubscriberInputScope,
9903}
9904
9905impl<N: Network> SubscriberInputRecord<N> {
9906    /// Borrow the reactive input record.
9907    pub const fn record(&self) -> &ReactiveInputRecord<N> {
9908        &self.record
9909    }
9910
9911    /// Delivery audience captured when the record was enqueued.
9912    pub const fn scope(&self) -> &SubscriberInputScope {
9913        &self.scope
9914    }
9915
9916    /// Consume the scoped value into its reactive input record.
9917    pub fn into_record(self) -> ReactiveInputRecord<N> {
9918        self.record
9919    }
9920}
9921
9922impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
9923    type Target = ReactiveInputRecord<N>;
9924
9925    fn deref(&self) -> &Self::Target {
9926        &self.record
9927    }
9928}
9929
9930/// Batch of subscriber inputs with enqueue-time owner provenance.
9931#[derive(Clone, Debug)]
9932pub struct SubscriberInputBatch<N: Network = Ethereum> {
9933    records: Vec<SubscriberInputRecord<N>>,
9934    chain_id: Option<u64>,
9935    chain_controls: Vec<ChainControl>,
9936}
9937
9938/// Result of polling a scoped subscriber batch against one driver control
9939/// future.
9940#[derive(Debug)]
9941#[non_exhaustive]
9942pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
9943    /// The control future completed first; subscriber delivery remains intact.
9944    Control(C),
9945    /// Subscriber polling completed first.
9946    Batch(Option<SubscriberInputBatch<N>>),
9947}
9948
9949impl<N: Network> SubscriberInputBatch<N> {
9950    /// Borrow every scoped record in delivery order.
9951    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
9952        &self.records
9953    }
9954
9955    /// Consume the batch into its scoped records.
9956    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
9957        self.records
9958    }
9959
9960    /// Ordered chain controls committed after the preceding records.
9961    pub fn chain_controls(&self) -> &[ChainControl] {
9962        &self.chain_controls
9963    }
9964
9965    /// Consume the scoped subscriber delivery into a runtime-ready batch.
9966    ///
9967    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
9968    /// allowing downstream owner actors to forward a batch without rebuilding
9969    /// subscriber-internal scope metadata.
9970    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
9971        let chain_id = self.chain_id;
9972        let chain_controls = self.chain_controls;
9973        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
9974            self.records.into_iter().map(|scoped| {
9975                let source = scoped.record.context.source;
9976                let (audience, delivery_scope) = match scoped.scope {
9977                    SubscriberInputScope::Canonical { .. } => (
9978                        DeliveryAudience::All,
9979                        if source == InputSource::Backfill {
9980                            DeliveryScope::CanonicalProgress
9981                        } else {
9982                            DeliveryScope::Canonical
9983                        },
9984                    ),
9985                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
9986                        DeliveryAudience::AllExcept(excluded),
9987                        if source == InputSource::Backfill {
9988                            DeliveryScope::CanonicalProgress
9989                        } else {
9990                            DeliveryScope::Canonical
9991                        },
9992                    ),
9993                    SubscriberInputScope::OwnerOnly { owners } => {
9994                        let mut handler_ids = Vec::with_capacity(owners.len());
9995                        for epoch in owners {
9996                            if !handler_ids.contains(epoch.owner()) {
9997                                handler_ids.push(epoch.owner().clone());
9998                            }
9999                        }
10000                        (
10001                            DeliveryAudience::Owners(handler_ids),
10002                            DeliveryScope::OwnerCatchup,
10003                        )
10004                    }
10005                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
10006                        DeliveryAudience::Owners(owners),
10007                        DeliveryScope::OwnerCatchup,
10008                    ),
10009                    SubscriberInputScope::Preconfirmed => {
10010                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
10011                    }
10012                };
10013                (scoped.record, audience, delivery_scope)
10014            }),
10015        )
10016        .with_chain_controls(chain_controls);
10017        if let Some(chain_id) = chain_id {
10018            batch = batch.with_chain_id(chain_id);
10019        }
10020        batch
10021    }
10022}
10023
10024impl SubscriberOwnerEpoch {
10025    /// Logical subscriber owner represented by this epoch.
10026    pub const fn owner(&self) -> &HandlerId {
10027        &self.owner
10028    }
10029
10030    /// Monotonic subscriber-local epoch sequence.
10031    pub const fn sequence(&self) -> u64 {
10032        self.sequence
10033    }
10034}
10035
10036/// Catch-up policy applied when staging a transaction-aware interest owner.
10037#[derive(Clone, Debug, PartialEq, Eq)]
10038#[non_exhaustive]
10039pub enum SubscriberOwnerStart {
10040    /// Start with live delivery only.
10041    Live,
10042    /// Start strictly after an already-applied post-block baseline.
10043    ///
10044    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
10045    /// itself is never replayed. Transaction-aware callers explicitly call
10046    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
10047    /// owners never use the legacy lazy-backfill queue.
10048    PostBlock(BlockRef),
10049}
10050
10051/// Transaction state of one epoch-scoped subscriber owner.
10052#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10053#[non_exhaustive]
10054pub enum SubscriberOwnerState {
10055    /// Desired interests and owner-scoped buffering are installed but canonical
10056    /// routing has not yet committed.
10057    Staged,
10058    /// Canonical runtime routing has committed for this owner.
10059    Active,
10060    /// Removal is prepared behind a delivery fence but remains reversible.
10061    Removing,
10062}
10063
10064/// Hash-certified catch-up position reached by one subscriber owner epoch.
10065///
10066/// Progress means every owner-only record through this point has been fetched
10067/// and queued inside the subscriber. It does not mean the downstream actor has
10068/// drained or committed those records; that requires a separate delivery fence.
10069#[derive(Clone, Debug, PartialEq, Eq)]
10070pub struct SubscriberOwnerProgress {
10071    owner: SubscriberOwnerEpoch,
10072    through: BlockRef,
10073}
10074
10075impl SubscriberOwnerProgress {
10076    /// Exact owner epoch whose catch-up was reconciled.
10077    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
10078        &self.owner
10079    }
10080
10081    /// Verified canonical block through which owner input was fetched.
10082    pub const fn through(&self) -> &BlockRef {
10083        &self.through
10084    }
10085}
10086
10087/// Error staging a transaction-aware subscriber owner.
10088#[derive(Debug, thiserror::Error)]
10089#[non_exhaustive]
10090pub enum SubscriberOwnerError {
10091    /// Subscriber configuration or interest validation failed.
10092    #[error(transparent)]
10093    Subscriber(#[from] SubscriberError),
10094    /// The logical owner already has desired interests installed.
10095    #[error("subscriber interest owner `{0}` is already registered")]
10096    AlreadyRegistered(HandlerId),
10097    /// A post-block baseline cannot be advanced to its first unapplied block.
10098    #[error("post-block subscriber baseline {0} has no following block")]
10099    PostBlockOverflow(u64),
10100    /// The monotonic subscriber owner epoch sequence was exhausted.
10101    #[error("subscriber owner epoch sequence exhausted")]
10102    EpochExhausted,
10103    /// The exact owner epoch is unknown or no longer staged.
10104    #[error("subscriber owner epoch is not staged")]
10105    NotStaged,
10106    /// Live-only staging has no historical baseline to reconcile.
10107    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
10108    MissingBaseline,
10109    /// Post-block reconciliation currently covers log interests only.
10110    #[error("post-block subscriber owners support log interests only")]
10111    UnsupportedPostBlockInterest,
10112    /// The target block was absent from the provider.
10113    #[error("subscriber reconcile target block {0} was not found")]
10114    BlockUnavailable(u64),
10115    /// The provider's canonical identity did not match the requested target.
10116    #[error(
10117        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
10118    )]
10119    BlockMismatch {
10120        /// Requested block number.
10121        expected_number: u64,
10122        /// Requested block hash.
10123        expected_hash: B256,
10124        /// Provider block number.
10125        actual_number: u64,
10126        /// Provider block hash.
10127        actual_hash: B256,
10128    },
10129    /// A reconcile target was older than the retained baseline/progress.
10130    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
10131    ProgressRegression {
10132        /// Retained baseline or progress block.
10133        current: u64,
10134        /// Rejected target block.
10135        target: u64,
10136    },
10137    /// A reconcile attempted to replace a retained block identity at the same
10138    /// height or cross an immediate parent that does not extend it.
10139    #[error(
10140        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
10141    )]
10142    ProgressConflict {
10143        /// Retained baseline or progress block number.
10144        number: u64,
10145        /// Retained baseline or progress block hash.
10146        current_hash: B256,
10147        /// Conflicting target hash or immediate parent hash.
10148        target_hash: B256,
10149    },
10150    /// A provider returned a malformed or out-of-range catch-up log.
10151    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
10152    InvalidBackfillLog(&'static str),
10153}
10154
10155/// Extension trait for subscribers that can add and remove handler-owned
10156/// interests incrementally.
10157///
10158/// [`EventSubscriber::register_interests`] remains the full-replacement setup
10159/// API. Implement this trait when a subscriber can preserve unrelated live
10160/// sources and delivery state while one handler's interests are added or
10161/// removed. Implementations should make owner *replacement* continuity-safe:
10162/// updating an owner's interests must not silently discard delivery progress
10163/// the previous interests had already established (the in-crate
10164/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
10165/// changed filter shapes and automatically backfills the gap). Every mutating
10166/// operation is also a commit boundary: returning `Ok` means the new desired
10167/// state is authoritative, while errors or cancellation must preserve the
10168/// previous state or reconcile before exposing the uncommitted change.
10169pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
10170    /// Atomically add or replace several owners in one desired-state revision.
10171    ///
10172    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
10173    /// boundary for the complete set; an error or cancellation must leave the
10174    /// previously committed owner topology authoritative. Durable remote
10175    /// subscribers should override this method so bootstrap creates one service
10176    /// revision and one activation barrier rather than one barrier per owner.
10177    ///
10178    /// # Errors
10179    ///
10180    /// The returned operation reports [`SubscriberError::Unsupported`] by
10181    /// default, or an implementation-specific validation or commit failure.
10182    fn upsert_interest_owners(
10183        &mut self,
10184        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10185    ) -> SubscriberOperation<'_, ()> {
10186        Box::pin(async {
10187            Err(SubscriberError::Unsupported(
10188                "subscriber does not implement atomic bulk owner upsert",
10189            ))
10190        })
10191    }
10192
10193    /// Atomically replace the complete engine-managed owner topology without
10194    /// requesting history.
10195    ///
10196    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
10197    /// stale owners, queued delivery, and dedupe/source state from the prior
10198    /// topology must not survive a successful replacement. Errors and dropped
10199    /// futures leave the prior committed topology authoritative.
10200    ///
10201    /// # Errors
10202    ///
10203    /// The returned operation reports [`SubscriberError::Unsupported`] by
10204    /// default, or an implementation-specific validation or commit failure.
10205    fn replace_interest_owners(
10206        &mut self,
10207        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10208    ) -> SubscriberOperation<'_, ()> {
10209        Box::pin(async {
10210            Err(SubscriberError::Unsupported(
10211                "subscriber does not implement atomic exact owner replacement",
10212            ))
10213        })
10214    }
10215
10216    /// Atomically replace the complete owner set and schedule one global
10217    /// historical log backfill in the same desired-state revision.
10218    ///
10219    /// This is the continuity-safe bootstrap operation for a runtime that has
10220    /// already processed canonical state while the subscriber's owner state is
10221    /// new or may have been lost. Implementations must commit the complete
10222    /// owner topology and all required historical work together: returning an
10223    /// error or dropping the future must leave the previously committed state
10224    /// authoritative. The default is deliberately unsupported rather than a
10225    /// sequence of partially committed single-owner updates.
10226    /// Historical records must be delivered through canonical global routing
10227    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
10228    /// owner catch-up, so their effects participate in the normal rollback
10229    /// journal before the source certifies the cutover. Base/unowned interests
10230    /// are replaced by this complete engine-managed topology. Any owner absent
10231    /// from `owners` must be removed together with its queued owner-only work, which closes
10232    /// the crash window where a subscriber committed registration but the
10233    /// runtime process died before installing the corresponding handler.
10234    ///
10235    /// # Errors
10236    ///
10237    /// The returned operation reports [`SubscriberError::Unsupported`] by
10238    /// default, or a backfill, validation, transport, or atomic-commit failure.
10239    fn replace_interest_owners_with_global_backfill(
10240        &mut self,
10241        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10242        _backfill: SubscriberBackfill,
10243    ) -> SubscriberOperation<'_, ()> {
10244        Box::pin(async {
10245            Err(SubscriberError::Unsupported(
10246                "subscriber does not implement atomic owner replacement with global backfill",
10247            ))
10248        })
10249    }
10250
10251    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
10252    /// commit boundary.
10253    ///
10254    /// Implementations must leave the previously committed owner state
10255    /// authoritative when the operation returns an error or is cancelled before
10256    /// completion.
10257    ///
10258    /// # Errors
10259    ///
10260    /// The returned operation reports [`SubscriberError`] when the owner update
10261    /// cannot be validated or committed.
10262    fn add_interest_owner(
10263        &mut self,
10264        owner: HandlerId,
10265        interests: &[ReactiveInterest<N>],
10266    ) -> SubscriberOperation<'_, ()>;
10267
10268    /// Add or replace owner interests and schedule log backfill for that owner,
10269    /// awaiting the subscriber's commit boundary.
10270    ///
10271    /// # Errors
10272    ///
10273    /// The returned operation reports [`SubscriberError`] when the owner update
10274    /// or requested backfill cannot be validated or committed.
10275    fn add_interest_owner_with_backfill(
10276        &mut self,
10277        owner: HandlerId,
10278        interests: &[ReactiveInterest<N>],
10279        backfill: SubscriberBackfill,
10280    ) -> SubscriberOperation<'_, ()>;
10281
10282    /// Add a handler discovered at retained canonical block `C` without
10283    /// opening a gap while registration commits.
10284    ///
10285    /// The subscriber must subscribe/adopt the new desired state first, then
10286    /// expose the new owner's matching records from `C` as owner catch-up and
10287    /// expose `C + 1` through the activation head as one globally ordered
10288    /// canonical catch-up over the complete active interest union. This split
10289    /// is deliberate: the runtime already has a rollback entry for `C`, while
10290    /// later blocks must run every handler and create normal canonical journal
10291    /// entries. Errors/cancellation preserve the prior committed topology.
10292    /// Implementations that cannot uphold this coordinated transaction must
10293    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
10294    ///
10295    /// # Errors
10296    ///
10297    /// The returned operation reports [`SubscriberError::Unsupported`] by
10298    /// default, or a canonical-anchor, transport, or atomic-commit failure.
10299    fn add_interest_owner_with_canonical_catchup(
10300        &mut self,
10301        _owner: HandlerId,
10302        _interests: &[ReactiveInterest<N>],
10303        _retained: BlockRef,
10304    ) -> SubscriberOperation<'_, ()> {
10305        Box::pin(async {
10306            Err(SubscriberError::Unsupported(
10307                "subscriber does not implement coordinated canonical owner catch-up",
10308            ))
10309        })
10310    }
10311
10312    /// Remove one owner's interests, preserving unrelated interests, and await
10313    /// acknowledgement that the removal committed.
10314    ///
10315    /// On error the owner must remain authoritative, so the runtime handler is
10316    /// not removed while subscriber delivery may still target it.
10317    ///
10318    /// # Errors
10319    ///
10320    /// The returned operation reports [`SubscriberError`] when the removal
10321    /// cannot be committed while preserving unrelated owners.
10322    fn remove_interest_owner(
10323        &mut self,
10324        owner: &HandlerId,
10325    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
10326
10327    /// Borrow the interests currently owned by `owner`.
10328    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
10329}
10330
10331/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
10332/// subscribe-ingest lifecycle.
10333///
10334/// The engine treats the runtime registry as the single source of truth for
10335/// handler lifecycle: [`register_handler`](Self::register_handler) and
10336/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
10337/// subscriber interests as one operation, keyed by the handler's stable
10338/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
10339/// has journaled canonical block *N*, a newly registered handler is live-adopted,
10340/// replayed owner-only at *N*, and then caught up globally with every handler
10341/// from *N + 1* through activation. A factory-discovered pool therefore misses
10342/// none of its own logs without making later history owner-local and
10343/// unrollbackable. The subscriber must absorb overlap that crosses batch
10344/// boundaries; the runtime validates and merges duplicate representations only
10345/// within one [`ReactiveInputBatch`].
10346///
10347/// Registration methods by intent:
10348///
10349/// | Method | Backfill |
10350/// |---|---|
10351/// | [`register_handler`](Self::register_handler) | coordinated owner replay at the last retained block plus global catch-up above it (live-only on a fresh runtime) |
10352/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
10353/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
10354///
10355/// Unregistering a handler stops future subscription routing and runtime
10356/// decode for that handler; it deliberately does not evict [`EvmCache`] state
10357/// or undo runtime side effects. See
10358/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
10359/// recipe.
10360///
10361/// The runtime and subscriber stay independently accessible through
10362/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
10363/// for advanced use. One caution: avoid calling
10364/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
10365/// an engine-managed subscriber — implementations may clear owner-scoped
10366/// bookkeeping, after which per-handler unregistration no longer releases the
10367/// handler's transport subscriptions. To bootstrap the subscriber from a
10368/// runtime that already has handlers, use
10369/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
10370/// one owner per handler instead of one unowned blob.
10371pub struct ReactiveEngine<S, N: Network = Ethereum> {
10372    runtime: ReactiveRuntime<N>,
10373    subscriber: S,
10374    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
10375    pending_checkpoint: Option<PendingCheckpoint<N>>,
10376    last_checkpoint_block: Option<DurableCheckpointBlock>,
10377    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
10378    last_checkpoint_delivery_witness: Option<B256>,
10379    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
10380    checkpoint_identity: Option<DurableCheckpointIdentity>,
10381}
10382
10383struct PendingAcknowledgement<N: Network> {
10384    token: SubscriberDeliveryToken,
10385    report: ReactiveBatchReport<N>,
10386}
10387
10388struct PendingCheckpoint<N: Network> {
10389    metadata: DurableCheckpointMetadata,
10390    delivery_token: Option<SubscriberDeliveryToken>,
10391    report: ReactiveBatchReport<N>,
10392    saved_to: Option<PathBuf>,
10393    staged_generation: u64,
10394}
10395
10396struct CheckpointStage<N: Network> {
10397    incoming_block: Option<DurableCheckpointBlock>,
10398    delivery_token: Option<SubscriberDeliveryToken>,
10399    delivery_witness: Option<B256>,
10400    subscriber_checkpoint: Option<SubscriberCheckpoint>,
10401    staged_generation: u64,
10402    report: ReactiveBatchReport<N>,
10403}
10404
10405struct DurableResumePlan {
10406    runtime: DurableRuntimeRestorePlan,
10407    position: SubscriberResumePosition,
10408    delivery_witness: Option<B256>,
10409}
10410
10411enum HandlerRegistrationCatchup {
10412    LiveOnly,
10413    OwnerBackfill(SubscriberBackfill),
10414    CoordinatedCanonical(BlockRef),
10415}
10416
10417const DELIVERY_WITNESS_VERSION: u32 = 1;
10418const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
10419
10420#[derive(serde::Serialize)]
10421struct DeliveryWitnessEnvelope<'a> {
10422    version: u32,
10423    chain_id: Option<u64>,
10424    records: Vec<DeliveryRecordWitness<'a>>,
10425    chain_controls: &'a [ChainControl],
10426    subscriber_checkpoint: Option<&'a [u8]>,
10427    payload_commitment: Option<B256>,
10428}
10429
10430#[derive(serde::Serialize)]
10431struct DeliveryRecordWitness<'a> {
10432    identity: ReactiveInputIdentity,
10433    context: &'a ReactiveContext,
10434    audience: &'a DeliveryAudience,
10435    scope: DeliveryScope,
10436    payload: DeliveryPayloadWitness<'a>,
10437}
10438
10439#[derive(serde::Serialize)]
10440enum DeliveryPayloadWitness<'a> {
10441    /// Logs are the primary state-bearing event representation, so retain every
10442    /// RPC payload field in addition to the validated identity/context.
10443    Log {
10444        address: Address,
10445        topics: &'a [B256],
10446        data: &'a Bytes,
10447        block_hash: Option<B256>,
10448        block_number: Option<u64>,
10449        block_timestamp: Option<u64>,
10450        transaction_hash: Option<B256>,
10451        transaction_index: Option<u64>,
10452        log_index: Option<u64>,
10453        removed: bool,
10454    },
10455    /// Network-generic response bodies do not expose one stable complete serde
10456    /// contract. Their validated identity/context are witnessed here; batches
10457    /// containing headers, full blocks, or hydrated transactions additionally
10458    /// require the source's exact canonical wire-payload commitment. A generic
10459    /// header response can expose a supplied hash without proving that every
10460    /// handler-visible inner field recomputes to it.
10461    IdentityCommitted,
10462}
10463
10464fn durable_delivery_witness<N: Network>(
10465    batch: &ReactiveInputBatch<N>,
10466) -> Result<B256, ReactiveEngineError> {
10467    let requires_payload_commitment = batch.records.iter().any(|record| {
10468        matches!(
10469            &record.input,
10470            ReactiveInput::BlockHeader(_)
10471                | ReactiveInput::FullBlock(_)
10472                | ReactiveInput::PendingTx(_)
10473        )
10474    });
10475    if requires_payload_commitment && batch.payload_commitment.is_none() {
10476        return Err(ReactiveEngineError::MissingPayloadCommitment);
10477    }
10478    let records = batch
10479        .records
10480        .iter()
10481        .enumerate()
10482        .map(|(index, record)| {
10483            let payload = match &record.input {
10484                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
10485                    address: log.address(),
10486                    topics: log.topics(),
10487                    data: &log.inner.data.data,
10488                    block_hash: log.block_hash,
10489                    block_number: log.block_number,
10490                    block_timestamp: log.block_timestamp,
10491                    transaction_hash: log.transaction_hash,
10492                    transaction_index: log.transaction_index,
10493                    log_index: log.log_index,
10494                    removed: log.removed,
10495                },
10496                ReactiveInput::BlockHeader(_)
10497                | ReactiveInput::FullBlock(_)
10498                | ReactiveInput::PendingTxHash(_)
10499                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
10500            };
10501            Ok(DeliveryRecordWitness {
10502                identity: record.validated_identity()?,
10503                context: &record.context,
10504                audience: batch
10505                    .record_audience(index)
10506                    .expect("enumerated record always has an audience"),
10507                scope: batch
10508                    .record_delivery_scope(index)
10509                    .expect("enumerated record always has a delivery scope"),
10510                payload,
10511            })
10512        })
10513        .collect::<Result<Vec<_>, ReactiveError>>()?;
10514    let envelope = DeliveryWitnessEnvelope {
10515        version: DELIVERY_WITNESS_VERSION,
10516        chain_id: batch.chain_id,
10517        records,
10518        chain_controls: &batch.chain_controls,
10519        subscriber_checkpoint: batch
10520            .subscriber_checkpoint
10521            .as_ref()
10522            .map(SubscriberCheckpoint::as_bytes),
10523        payload_commitment: batch
10524            .payload_commitment
10525            .as_ref()
10526            .map(SubscriberPayloadCommitment::digest),
10527    };
10528    let encoded = bincode::DefaultOptions::new()
10529        .with_fixint_encoding()
10530        .serialize(&envelope)
10531        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
10532    let mut witness = Keccak256::new();
10533    witness.update(DELIVERY_WITNESS_DOMAIN);
10534    witness.update(encoded);
10535    Ok(witness.finalize())
10536}
10537
10538impl<S, N> ReactiveEngine<S, N>
10539where
10540    N: Network,
10541    S: EventSubscriber<N>,
10542{
10543    /// Bind a runtime and subscriber.
10544    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
10545        Self {
10546            runtime,
10547            subscriber,
10548            pending_acknowledgement: None,
10549            pending_checkpoint: None,
10550            last_checkpoint_block: None,
10551            last_checkpoint_delivery_token: None,
10552            last_checkpoint_delivery_witness: None,
10553            last_subscriber_checkpoint: None,
10554            checkpoint_identity: None,
10555        }
10556    }
10557
10558    /// Split the engine into its runtime and subscriber parts when no commit is
10559    /// pending.
10560    ///
10561    /// A failed delivery acknowledgement or durable checkpoint commit remains
10562    /// live protocol state: dropping it would allow the caller to lose the
10563    /// already-applied report/token pair and poll past an uncommitted batch.
10564    /// In that case this returns the intact engine so the caller can repair the
10565    /// dependency and retry through the normal ingestion method.
10566    ///
10567    /// # Errors
10568    ///
10569    /// Returns the intact boxed engine when an acknowledgement or checkpoint
10570    /// commit is pending.
10571    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
10572        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
10573            return Err(Box::new(self));
10574        }
10575        Ok((self.runtime, self.subscriber))
10576    }
10577
10578    fn durable_resume_plan(
10579        &self,
10580        metadata: &DurableCheckpointMetadata,
10581    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
10582        if !self.subscriber.capabilities().supports_durable_replay() {
10583            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
10584        }
10585        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
10586        if !self.runtime.is_pristine_for_checkpoint_restore()
10587            || self.pending_acknowledgement.is_some()
10588            || self.pending_checkpoint.is_some()
10589            || self.last_checkpoint_block.is_some()
10590            || self.last_checkpoint_delivery_token.is_some()
10591            || self.last_checkpoint_delivery_witness.is_some()
10592            || self.last_subscriber_checkpoint.is_some()
10593            || self.checkpoint_identity.is_some()
10594        {
10595            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
10596        }
10597
10598        let block = BlockRef {
10599            number: metadata.block.number,
10600            hash: metadata.block.hash,
10601            parent_hash: metadata.block.parent_hash,
10602            timestamp: metadata.block.timestamp,
10603        };
10604        let runtime = match metadata.runtime_checkpoint.as_deref() {
10605            Some(bytes) => self
10606                .runtime
10607                .plan_durable_checkpoint_restore(bytes, &block)?,
10608            None => DurableRuntimeRestorePlan {
10609                checkpoint: None,
10610                fallback_history: (self.runtime.config.journal_depth > 0)
10611                    .then_some(block)
10612                    .into_iter()
10613                    .collect(),
10614            },
10615        };
10616        let delivery_token = metadata
10617            .delivery_token
10618            .clone()
10619            .map(SubscriberDeliveryToken::new);
10620        let subscriber_checkpoint = metadata
10621            .subscriber_checkpoint
10622            .clone()
10623            .map(SubscriberCheckpoint::new);
10624        let position = SubscriberResumePosition::new(
10625            metadata.identity.chain_id,
10626            block,
10627            runtime.canonical_history(),
10628            delivery_token,
10629            subscriber_checkpoint,
10630        );
10631        Ok(DurableResumePlan {
10632            runtime,
10633            position,
10634            delivery_witness: metadata.delivery_witness,
10635        })
10636    }
10637
10638    /// Preview the exact subscriber position a durable restore will install.
10639    ///
10640    /// This read-only step exists for durable subscribers that must complete
10641    /// asynchronous source or transport preparation before the engine invokes
10642    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
10643    /// and validates the core runtime checkpoint, applies this runtime's
10644    /// configured journal retention to the preview, and returns the same
10645    /// [`SubscriberResumePosition`] that
10646    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
10647    /// will later pass to the subscriber.
10648    ///
10649    /// Call this on the same fresh engine that will perform the restore. After
10650    /// subscriber preparation completes, pass the identical `metadata` to
10651    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
10652    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
10653    /// without mutating engine runtime or checkpoint state in between. The
10654    /// checkpoint identity and, for non-finalized state, its canonical block
10655    /// must still be validated by the caller before external preparation.
10656    ///
10657    /// This method does not mutate the runtime, subscriber, or checkpoint
10658    /// bookkeeping.
10659    ///
10660    /// # Errors
10661    ///
10662    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
10663    /// durable, its chain identity conflicts with the checkpoint, the engine is
10664    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
10665    /// or internally inconsistent.
10666    pub fn preview_durable_resume_position(
10667        &self,
10668        metadata: &DurableCheckpointMetadata,
10669    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
10670        Ok(self.durable_resume_plan(metadata)?.position)
10671    }
10672
10673    /// Resume delivery bookkeeping and canonical continuity from a cache
10674    /// checkpoint that has already been identity- and hash-validated and
10675    /// restored into [`EvmCache`].
10676    ///
10677    /// Call this on a fresh engine. The anchor has no rollback effects of its
10678    /// own: it represents the state baseline embodied by the checkpoint, while
10679    /// newly ingested blocks are journaled normally above it.
10680    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
10681    /// restoring an ephemeral stream would claim a restart guarantee it cannot
10682    /// uphold and is rejected before cache or runtime mutation.
10683    ///
10684    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
10685    /// when the cache has not yet been restored: that helper rolls the cache
10686    /// back as well if runtime or subscriber activation fails.
10687    ///
10688    /// # Errors
10689    ///
10690    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
10691    /// durable, chain identity conflicts, the runtime is not pristine, stored
10692    /// runtime state is invalid, or the subscriber rejects the restored
10693    /// position. Runtime state is restored on subscriber failure.
10694    pub fn resume_from_durable_checkpoint(
10695        &mut self,
10696        metadata: &DurableCheckpointMetadata,
10697    ) -> Result<(), ReactiveCheckpointRestoreError> {
10698        let plan = self.durable_resume_plan(metadata)?;
10699        let prior_runtime = self.runtime.checkpoint_state();
10700
10701        let DurableResumePlan {
10702            runtime,
10703            position,
10704            delivery_witness,
10705        } = plan;
10706        self.runtime.apply_durable_checkpoint_restore(runtime);
10707        self.runtime.coverage_head = Some(position.coverage_head);
10708        if let Err(error) = self.subscriber.restore_position(&position) {
10709            self.runtime.restore_state(prior_runtime);
10710            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
10711        }
10712        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
10713            self.runtime.restore_state(prior_runtime);
10714            return Err(error);
10715        }
10716        self.last_checkpoint_block = Some(metadata.block.clone());
10717        self.last_checkpoint_delivery_token = position.delivery_token;
10718        self.last_checkpoint_delivery_witness = delivery_witness;
10719        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
10720        self.checkpoint_identity = Some(metadata.identity.clone());
10721        Ok(())
10722    }
10723
10724    /// Atomically restore cache, runtime, and subscriber position from one
10725    /// validated durable checkpoint.
10726    ///
10727    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
10728    /// block against an authoritative RPC source before calling this method when
10729    /// the block is not finalized. Identity, cache-chain, runtime-state, and
10730    /// subscriber failures leave the cache and engine runtime unchanged. The
10731    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
10732    /// It must advertise [`SubscriberCapability::DurableReplay`].
10733    ///
10734    /// # Errors
10735    ///
10736    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
10737    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
10738    /// position-restore failures. Cache and runtime state remain unchanged.
10739    pub fn restore_durable_checkpoint(
10740        &mut self,
10741        cache: &mut EvmCache,
10742        loaded: LoadedDurableCheckpoint,
10743        expected: &DurableCheckpointIdentity,
10744    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
10745        if !self.subscriber.capabilities().supports_durable_replay() {
10746            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
10747        }
10748        self.ensure_subscriber_restore_chain(expected.chain_id)?;
10749        if !self.runtime.is_pristine_for_checkpoint_restore()
10750            || self.pending_acknowledgement.is_some()
10751            || self.pending_checkpoint.is_some()
10752            || self.last_checkpoint_block.is_some()
10753            || self.last_checkpoint_delivery_token.is_some()
10754            || self.last_checkpoint_delivery_witness.is_some()
10755            || self.last_subscriber_checkpoint.is_some()
10756            || self.checkpoint_identity.is_some()
10757        {
10758            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
10759        }
10760
10761        let prior_cache = EvmCacheStateSnapshot::capture(cache);
10762        let metadata = loaded.restore_into(cache, expected)?;
10763        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
10764            prior_cache.restore(cache);
10765            return Err(error);
10766        }
10767        Ok(metadata)
10768    }
10769
10770    /// Borrow the runtime.
10771    pub fn runtime(&self) -> &ReactiveRuntime<N> {
10772        &self.runtime
10773    }
10774
10775    /// Mutably borrow the runtime.
10776    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
10777        &mut self.runtime
10778    }
10779
10780    /// Borrow the subscriber.
10781    pub fn subscriber(&self) -> &S {
10782        &self.subscriber
10783    }
10784
10785    /// Mutably borrow the subscriber.
10786    pub fn subscriber_mut(&mut self) -> &mut S {
10787        &mut self.subscriber
10788    }
10789
10790    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
10791    /// cold-start baseline.
10792    ///
10793    /// The cache must use the exact canonical hash selector and block-number
10794    /// context named by `baseline`; when the baseline includes a timestamp, the
10795    /// cache timestamp must match too. Cache, baseline, and any already-resolved
10796    /// subscriber identity must name the same chain. No delivery or checkpoint
10797    /// commit may be pending. After this succeeds, call
10798    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
10799    /// before polling: it exact-replaces subscriber owners and begins event
10800    /// catch-up at `C + 1`.
10801    ///
10802    /// # Errors
10803    ///
10804    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
10805    /// is active or already has a conflicting baseline, cache/subscriber chain
10806    /// identity differs, or the cache is not pinned to the exact baseline.
10807    pub fn adopt_canonical_baseline(
10808        &mut self,
10809        cache: &EvmCache,
10810        baseline: ReactiveCanonicalBaseline,
10811    ) -> Result<(), ReactiveEngineError> {
10812        if self.pending_acknowledgement.is_some()
10813            || self.pending_checkpoint.is_some()
10814            || self.last_checkpoint_block.is_some()
10815            || self.last_checkpoint_delivery_token.is_some()
10816            || self.last_checkpoint_delivery_witness.is_some()
10817            || self.last_subscriber_checkpoint.is_some()
10818            || self.checkpoint_identity.is_some()
10819        {
10820            return Err(ReactiveBaselineError::ActiveRuntime.into());
10821        }
10822        // Establish deterministic lifecycle/idempotency semantics before
10823        // consulting mutable cache context. A conflicting repeat is a runtime
10824        // baseline conflict even if the caller also repointed the cache.
10825        self.runtime
10826            .validate_canonical_baseline_adoption(baseline.block)?;
10827        if baseline.chain_id != cache.chain_id() {
10828            return Err(ReactiveBaselineError::CacheChainMismatch {
10829                baseline_chain_id: baseline.chain_id,
10830                cache_chain_id: cache.chain_id(),
10831            }
10832            .into());
10833        }
10834        self.ensure_subscriber_chain(cache)?;
10835        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
10836        let context_matches = cache.block_number() == Some(baseline.block.number)
10837            && baseline
10838                .block
10839                .timestamp
10840                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
10841        if cache.block() != exact_selector || !context_matches {
10842            return Err(ReactiveBaselineError::CacheBlockMismatch {
10843                number: baseline.block.number,
10844                hash: baseline.block.hash,
10845            }
10846            .into());
10847        }
10848        self.runtime.adopt_canonical_baseline(baseline.block)?;
10849        Ok(())
10850    }
10851
10852    /// Poll the subscriber for the next batch without ingesting it.
10853    ///
10854    /// This low-level escape hatch is unavailable while the engine owes an
10855    /// acknowledgement or checkpoint commit. Callers that use it must return
10856    /// any subscriber-owned delivery metadata through a combined
10857    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
10858    /// rejects that metadata so it cannot be discarded accidentally.
10859    ///
10860    /// # Errors
10861    ///
10862    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
10863    /// is pending or subscriber and cache chain identities conflict.
10864    pub fn next_batch(
10865        &mut self,
10866        cache: &EvmCache,
10867    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
10868        if self.pending_checkpoint.is_some() {
10869            return Err(ReactiveEngineError::PendingCheckpointCommit);
10870        }
10871        if self.pending_acknowledgement.is_some() {
10872            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
10873        }
10874        self.ensure_subscriber_chain(cache)?;
10875        Ok(self.subscriber.next_batch())
10876    }
10877
10878    /// Ingest one already-polled batch through the runtime (direct effects
10879    /// only; surfaced resync requests are reported, not executed).
10880    ///
10881    /// # Errors
10882    ///
10883    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
10884    /// carries subscriber-owned commit metadata, chain identity conflicts, or
10885    /// runtime ingestion fails.
10886    pub fn ingest_batch(
10887        &mut self,
10888        cache: &mut EvmCache,
10889        batch: ReactiveInputBatch<N>,
10890    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
10891        self.ensure_raw_ingest_is_safe(cache, &batch)?;
10892        Ok(self.runtime.ingest_batch(cache, batch)?)
10893    }
10894
10895    /// Ingest one already-polled batch and execute the storage/account resyncs
10896    /// it surfaces, exactly like
10897    /// [`ReactiveRuntime::ingest_batch_with_resync`].
10898    ///
10899    /// # Errors
10900    ///
10901    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
10902    /// carries subscriber-owned commit metadata, chain identity conflicts, or
10903    /// runtime ingestion fails.
10904    pub fn ingest_batch_with_resync(
10905        &mut self,
10906        cache: &mut EvmCache,
10907        batch: ReactiveInputBatch<N>,
10908    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
10909        self.ensure_raw_ingest_is_safe(cache, &batch)?;
10910        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
10911    }
10912
10913    fn ensure_raw_ingest_is_safe(
10914        &self,
10915        cache: &EvmCache,
10916        batch: &ReactiveInputBatch<N>,
10917    ) -> Result<(), ReactiveEngineError> {
10918        if self.pending_checkpoint.is_some() {
10919            return Err(ReactiveEngineError::PendingCheckpointCommit);
10920        }
10921        if self.pending_acknowledgement.is_some() {
10922            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
10923        }
10924        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
10925            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
10926        }
10927        self.ensure_subscriber_chain(cache)?;
10928        Ok(())
10929    }
10930
10931    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
10932        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
10933            && subscriber_chain_id != cache.chain_id()
10934        {
10935            return Err(ReactiveEngineError::SubscriberChainMismatch {
10936                subscriber_chain_id,
10937                cache_chain_id: cache.chain_id(),
10938            });
10939        }
10940        Ok(())
10941    }
10942
10943    fn ensure_subscriber_restore_chain(
10944        &self,
10945        checkpoint_chain_id: u64,
10946    ) -> Result<(), ReactiveCheckpointRestoreError> {
10947        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
10948            && subscriber_chain_id != checkpoint_chain_id
10949        {
10950            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
10951                subscriber_chain_id,
10952                checkpoint_chain_id,
10953            });
10954        }
10955        Ok(())
10956    }
10957
10958    /// Poll the subscriber once and ingest the returned batch when present
10959    /// (direct effects only).
10960    ///
10961    /// # Errors
10962    ///
10963    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
10964    /// pending checkpoint state, subscriber polling, runtime ingestion, or
10965    /// delivery-acknowledgement failure. A failed acknowledgement remains
10966    /// pending and is retried before polling again.
10967    pub async fn next_ingest(
10968        &mut self,
10969        cache: &mut EvmCache,
10970    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
10971        self.ensure_subscriber_chain(cache)?;
10972        if self.pending_checkpoint.is_some() {
10973            return Err(ReactiveEngineError::PendingCheckpointCommit);
10974        }
10975        if self.pending_acknowledgement.is_some() {
10976            return self.commit_pending_acknowledgement().await.map(Some);
10977        }
10978        let batch = self.subscriber.next_batch().await?;
10979        self.ensure_subscriber_chain(cache)?;
10980        let Some(mut batch) = batch else {
10981            return Ok(None);
10982        };
10983        let delivery_token = batch.take_delivery_token();
10984        let report = self.runtime.ingest_batch(cache, batch)?;
10985        self.stage_or_return_acknowledgement(delivery_token, report)
10986            .await
10987    }
10988
10989    /// Poll the subscriber once and ingest the returned batch with resync
10990    /// execution — the loop shape for consumers that rely on coverage-gap
10991    /// repair (root-gate resyncs, handler-requested re-reads).
10992    ///
10993    /// # Errors
10994    ///
10995    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
10996    /// pending checkpoint state, subscriber polling, runtime ingestion, or
10997    /// delivery-acknowledgement failure. A failed acknowledgement remains
10998    /// pending and is retried before polling again.
10999    pub async fn next_ingest_with_resync(
11000        &mut self,
11001        cache: &mut EvmCache,
11002    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11003        self.ensure_subscriber_chain(cache)?;
11004        if self.pending_checkpoint.is_some() {
11005            return Err(ReactiveEngineError::PendingCheckpointCommit);
11006        }
11007        if self.pending_acknowledgement.is_some() {
11008            return self.commit_pending_acknowledgement().await.map(Some);
11009        }
11010        let batch = self.subscriber.next_batch().await?;
11011        self.ensure_subscriber_chain(cache)?;
11012        let Some(mut batch) = batch else {
11013            return Ok(None);
11014        };
11015        let delivery_token = batch.take_delivery_token();
11016        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
11017        self.stage_or_return_acknowledgement(delivery_token, report)
11018            .await
11019    }
11020
11021    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
11022    ///
11023    /// The ordering is strict: subscriber acknowledgement is never attempted
11024    /// until the complete cache checkpoint is synced. If checkpointing or
11025    /// acknowledgement fails, the in-memory pending commit is retried before
11026    /// any later batch is polled, so a transient disk failure cannot cause the
11027    /// already-applied batch to execute twice in the same process. Across a
11028    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11029    /// uses the stored delivery token and delivery witness to recognize and
11030    /// acknowledge an identical replay without re-ingestion. Reusing a token
11031    /// for different input or cursor state fails closed. Mutating the cache while
11032    /// a commit is pending also fails closed rather than binding newer state to
11033    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
11034    /// that cannot be proven from the retained effect journal is rejected before
11035    /// mutation/save/ACK; configure
11036    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
11037    /// Hooks are dispatched only after checkpoint staging
11038    /// succeeds, but remain in-process observers rather than a durable outbox;
11039    /// see [`ReactiveHook`]. The subscriber must advertise
11040    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
11041    /// rejected before polling.
11042    ///
11043    /// # Errors
11044    ///
11045    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
11046    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
11047    /// an incompatible state, polling or ingestion fails, complete rollback
11048    /// proof is unavailable, the cache changes after staging, persistence
11049    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
11050    /// retained for retry before another poll.
11051    pub async fn next_ingest_checkpointed(
11052        &mut self,
11053        cache: &mut EvmCache,
11054        store: &DurableCheckpointStore,
11055        identity: &DurableCheckpointIdentity,
11056    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11057        if !self.subscriber.capabilities().supports_durable_replay() {
11058            return Err(ReactiveEngineError::SubscriberNotDurable);
11059        }
11060        self.ensure_subscriber_chain(cache)?;
11061        if self.pending_acknowledgement.is_some() {
11062            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11063        }
11064        self.ensure_checkpoint_identity(cache, identity)?;
11065        if self.pending_checkpoint.is_some() {
11066            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11067        }
11068
11069        let batch = self.subscriber.next_batch().await?;
11070        self.ensure_subscriber_chain(cache)?;
11071        let Some(mut batch) = batch else {
11072            return Ok(None);
11073        };
11074        if batch_preconfirmation(&batch)?.is_some() {
11075            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11076        }
11077        self.runtime.discard_preconfirmed_branch(cache);
11078        let delivery_witness = batch
11079            .delivery_token()
11080            .map(|_| durable_delivery_witness(&batch))
11081            .transpose()?;
11082        let delivery_token = batch.take_delivery_token();
11083        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11084        if let (Some(replay_token), Some(committed_token)) = (
11085            delivery_token.as_ref(),
11086            self.last_checkpoint_delivery_token.as_ref(),
11087        ) && replay_token == committed_token
11088        {
11089            let committed_witness = self
11090                .last_checkpoint_delivery_witness
11091                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11092            if delivery_witness != Some(committed_witness) {
11093                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11094            }
11095            self.subscriber
11096                .acknowledge_delivery(replay_token.clone())
11097                .await
11098                .map_err(ReactiveEngineError::Acknowledgement)?;
11099            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11100        }
11101
11102        self.ensure_checkpointable_reorgs(&batch)?;
11103
11104        let incoming_block = latest_canonical_batch_block(&batch);
11105        let cache_state = EvmCacheStateSnapshot::capture(cache);
11106        let runtime_state = self.runtime.checkpoint_state();
11107        let report = match self.runtime.ingest_batch_direct(cache, batch) {
11108            Ok(report) => report,
11109            Err(error) => {
11110                cache_state.restore(cache);
11111                self.runtime.restore_transaction_state(runtime_state);
11112                return Err(error.into());
11113            }
11114        };
11115        let reports = report.reports.clone();
11116        let stage = CheckpointStage {
11117            incoming_block,
11118            delivery_token,
11119            delivery_witness,
11120            subscriber_checkpoint,
11121            staged_generation: cache.snapshot_generation(),
11122            report,
11123        };
11124        if let Err(error) = self.stage_checkpoint(identity, stage) {
11125            cache_state.restore(cache);
11126            self.runtime.restore_transaction_state(runtime_state);
11127            return Err(error);
11128        }
11129        self.runtime.dispatch_reports(&reports);
11130        self.commit_pending_checkpoint(cache, store).await.map(Some)
11131    }
11132
11133    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
11134    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
11135    /// ephemeral subscriber before polling.
11136    ///
11137    /// # Errors
11138    ///
11139    /// Returns [`ReactiveEngineError`] for the same durability, identity,
11140    /// rollback-proof, replay-witness, polling, ingestion, persistence,
11141    /// mutation-fence, and acknowledgement failures as
11142    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
11143    pub async fn next_ingest_with_resync_checkpointed(
11144        &mut self,
11145        cache: &mut EvmCache,
11146        store: &DurableCheckpointStore,
11147        identity: &DurableCheckpointIdentity,
11148    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11149        if !self.subscriber.capabilities().supports_durable_replay() {
11150            return Err(ReactiveEngineError::SubscriberNotDurable);
11151        }
11152        self.ensure_subscriber_chain(cache)?;
11153        if self.pending_acknowledgement.is_some() {
11154            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11155        }
11156        self.ensure_checkpoint_identity(cache, identity)?;
11157        if self.pending_checkpoint.is_some() {
11158            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11159        }
11160
11161        let batch = self.subscriber.next_batch().await?;
11162        self.ensure_subscriber_chain(cache)?;
11163        let Some(mut batch) = batch else {
11164            return Ok(None);
11165        };
11166        if batch_preconfirmation(&batch)?.is_some() {
11167            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11168        }
11169        self.runtime.discard_preconfirmed_branch(cache);
11170        let delivery_witness = batch
11171            .delivery_token()
11172            .map(|_| durable_delivery_witness(&batch))
11173            .transpose()?;
11174        let delivery_token = batch.take_delivery_token();
11175        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11176        if let (Some(replay_token), Some(committed_token)) = (
11177            delivery_token.as_ref(),
11178            self.last_checkpoint_delivery_token.as_ref(),
11179        ) && replay_token == committed_token
11180        {
11181            let committed_witness = self
11182                .last_checkpoint_delivery_witness
11183                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11184            if delivery_witness != Some(committed_witness) {
11185                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11186            }
11187            self.subscriber
11188                .acknowledge_delivery(replay_token.clone())
11189                .await
11190                .map_err(ReactiveEngineError::Acknowledgement)?;
11191            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11192        }
11193
11194        self.ensure_checkpointable_reorgs(&batch)?;
11195
11196        let incoming_block = latest_canonical_batch_block(&batch);
11197        let cache_state = EvmCacheStateSnapshot::capture(cache);
11198        let runtime_state = self.runtime.checkpoint_state();
11199        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
11200            Ok(report) => report,
11201            Err(error) => {
11202                cache_state.restore(cache);
11203                self.runtime.restore_transaction_state(runtime_state);
11204                return Err(error.into());
11205            }
11206        };
11207        let reports = report.reports.clone();
11208        let stage = CheckpointStage {
11209            incoming_block,
11210            delivery_token,
11211            delivery_witness,
11212            subscriber_checkpoint,
11213            staged_generation: cache.snapshot_generation(),
11214            report,
11215        };
11216        if let Err(error) = self.stage_checkpoint(identity, stage) {
11217            cache_state.restore(cache);
11218            self.runtime.restore_transaction_state(runtime_state);
11219            return Err(error);
11220        }
11221        self.runtime.dispatch_reports(&reports);
11222        self.commit_pending_checkpoint(cache, store).await.map(Some)
11223    }
11224
11225    fn stage_checkpoint(
11226        &mut self,
11227        identity: &DurableCheckpointIdentity,
11228        stage: CheckpointStage<N>,
11229    ) -> Result<(), ReactiveEngineError> {
11230        let CheckpointStage {
11231            incoming_block,
11232            delivery_token,
11233            delivery_witness,
11234            subscriber_checkpoint,
11235            staged_generation,
11236            report,
11237        } = stage;
11238        if delivery_token.is_some() != delivery_witness.is_some() {
11239            return Err(ReactiveEngineError::DeliveryWitness(
11240                "delivery token and witness must be staged together".into(),
11241            ));
11242        }
11243        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
11244        let block = self
11245            .runtime
11246            .last_canonical_block()
11247            .map(|block| DurableCheckpointBlock {
11248                number: block.number,
11249                hash: block.hash,
11250                parent_hash: block.parent_hash,
11251                timestamp: block.timestamp,
11252            })
11253            .or(incoming_block)
11254            .or_else(|| self.last_checkpoint_block.clone())
11255            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
11256        let metadata = DurableCheckpointMetadata {
11257            identity: identity.clone(),
11258            block,
11259            delivery_token: delivery_token
11260                .as_ref()
11261                .or(self.last_checkpoint_delivery_token.as_ref())
11262                .map(|token| token.as_bytes().to_vec()),
11263            delivery_witness: if delivery_token.is_some() {
11264                delivery_witness
11265            } else {
11266                self.last_checkpoint_delivery_witness
11267            },
11268            subscriber_checkpoint: subscriber_checkpoint
11269                .as_ref()
11270                .or(self.last_subscriber_checkpoint.as_ref())
11271                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
11272            runtime_checkpoint: Some(runtime_checkpoint),
11273        };
11274        self.pending_checkpoint = Some(PendingCheckpoint {
11275            metadata,
11276            delivery_token,
11277            report,
11278            saved_to: None,
11279            staged_generation,
11280        });
11281        Ok(())
11282    }
11283
11284    fn ensure_checkpointable_reorgs(
11285        &self,
11286        batch: &ReactiveInputBatch<N>,
11287    ) -> Result<(), ReactiveEngineError> {
11288        let state = CanonicalSequenceState::new(
11289            self.runtime
11290                .journal
11291                .iter()
11292                .map(|entry| entry.block)
11293                .collect(),
11294            self.runtime.coverage_head,
11295            self.runtime.safe_head,
11296            self.runtime.finalized_head,
11297        );
11298        match validate_canonical_sequence_internal(
11299            &state,
11300            batch,
11301            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
11302        ) {
11303            Ok(_) => Ok(()),
11304            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
11305            Err(CanonicalSequenceError::IncompleteRollback {
11306                common_ancestor,
11307                oldest_retained,
11308                ..
11309            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
11310                common_ancestor,
11311                oldest_journaled: oldest_retained,
11312                journal_depth: self.runtime.config.journal_depth,
11313            }),
11314        }
11315    }
11316
11317    async fn stage_or_return_acknowledgement(
11318        &mut self,
11319        delivery_token: Option<SubscriberDeliveryToken>,
11320        report: ReactiveBatchReport<N>,
11321    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11322        let Some(token) = delivery_token else {
11323            return Ok(Some(report));
11324        };
11325        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
11326        self.commit_pending_acknowledgement().await.map(Some)
11327    }
11328
11329    async fn commit_pending_acknowledgement(
11330        &mut self,
11331    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11332        let token = self
11333            .pending_acknowledgement
11334            .as_ref()
11335            .expect("caller checked pending acknowledgement")
11336            .token
11337            .clone();
11338        self.subscriber
11339            .acknowledge_delivery(token)
11340            .await
11341            .map_err(ReactiveEngineError::Acknowledgement)?;
11342        Ok(self
11343            .pending_acknowledgement
11344            .take()
11345            .expect("pending acknowledgement remains until commit")
11346            .report)
11347    }
11348
11349    async fn commit_pending_checkpoint(
11350        &mut self,
11351        cache: &EvmCache,
11352        store: &DurableCheckpointStore,
11353    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
11354        let pending = self
11355            .pending_checkpoint
11356            .as_mut()
11357            .expect("caller checked pending checkpoint");
11358        let cache_generation = cache.snapshot_generation();
11359        if cache_generation != pending.staged_generation {
11360            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
11361                staged_generation: pending.staged_generation,
11362                current_generation: cache_generation,
11363            });
11364        }
11365        if pending.saved_to.as_deref() != Some(store.path()) {
11366            store
11367                .save_async(cache, pending.metadata.clone())
11368                .await
11369                .map_err(ReactiveEngineError::Checkpoint)?;
11370            pending.saved_to = Some(store.path().to_path_buf());
11371        }
11372        if let Some(token) = pending.delivery_token.clone() {
11373            self.subscriber
11374                .acknowledge_delivery(token)
11375                .await
11376                .map_err(ReactiveEngineError::Acknowledgement)?;
11377        }
11378
11379        let pending = self
11380            .pending_checkpoint
11381            .take()
11382            .expect("pending checkpoint remains until commit");
11383        self.last_checkpoint_block = Some(pending.metadata.block);
11384        self.checkpoint_identity = Some(pending.metadata.identity);
11385        self.last_checkpoint_delivery_token = pending
11386            .metadata
11387            .delivery_token
11388            .map(SubscriberDeliveryToken::new);
11389        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
11390        self.last_subscriber_checkpoint = pending
11391            .metadata
11392            .subscriber_checkpoint
11393            .map(SubscriberCheckpoint::new);
11394        Ok(CheckpointedIngest::Applied(pending.report))
11395    }
11396
11397    fn ensure_checkpoint_identity(
11398        &self,
11399        cache: &EvmCache,
11400        identity: &DurableCheckpointIdentity,
11401    ) -> Result<(), ReactiveEngineError> {
11402        if identity.chain_id != cache.chain_id() {
11403            return Err(ReactiveEngineError::Checkpoint(
11404                DurableCheckpointError::CacheChainMismatch {
11405                    cache_chain_id: cache.chain_id(),
11406                    checkpoint_chain_id: identity.chain_id,
11407                },
11408            ));
11409        }
11410        if let Some(actual) = self.checkpoint_identity.as_ref()
11411            && actual != identity
11412        {
11413            return Err(ReactiveEngineError::Checkpoint(
11414                DurableCheckpointError::IdentityMismatch {
11415                    expected: identity.clone(),
11416                    actual: actual.clone(),
11417                },
11418            ));
11419        }
11420        if let Some(pending) = self.pending_checkpoint.as_ref()
11421            && &pending.metadata.identity != identity
11422        {
11423            return Err(ReactiveEngineError::Checkpoint(
11424                DurableCheckpointError::IdentityMismatch {
11425                    expected: identity.clone(),
11426                    actual: pending.metadata.identity.clone(),
11427                },
11428            ));
11429        }
11430        Ok(())
11431    }
11432}
11433
11434fn latest_canonical_batch_block<N: Network>(
11435    batch: &ReactiveInputBatch<N>,
11436) -> Option<DurableCheckpointBlock> {
11437    let record_block = batch
11438        .records()
11439        .iter()
11440        .enumerate()
11441        .filter(|(index, _)| {
11442            batch
11443                .record_delivery_scope(*index)
11444                .is_some_and(DeliveryScope::advances_canonical_state)
11445        })
11446        .filter_map(|(_, record)| canonical_record_block(record))
11447        .max_by_key(|block| block.number)
11448        .cloned();
11449    let control_block = batch
11450        .chain_controls()
11451        .iter()
11452        .filter_map(|control| match control {
11453            ChainControl::Reorg {
11454                common_ancestor, ..
11455            } => Some(common_ancestor),
11456            ChainControl::Barrier {
11457                block: Some(block), ..
11458            }
11459            | ChainControl::CanonicalProgress(block) => Some(block),
11460            ChainControl::Safe(_)
11461            | ChainControl::Finalized(_)
11462            | ChainControl::Barrier { block: None, .. } => None,
11463        })
11464        .max_by_key(|block| block.number)
11465        .cloned();
11466
11467    record_block
11468        .into_iter()
11469        .chain(control_block)
11470        .max_by_key(|block| block.number)
11471        .map(|block| DurableCheckpointBlock {
11472            number: block.number,
11473            hash: block.hash,
11474            parent_hash: block.parent_hash,
11475            timestamp: block.timestamp,
11476        })
11477}
11478
11479impl<S, N> ReactiveEngine<S, N>
11480where
11481    N: Network,
11482    S: InterestOwnerSubscriber<N>,
11483{
11484    /// Register a handler with both the runtime and subscriber, backfilling its
11485    /// log interests from the runtime's last canonical block.
11486    ///
11487    /// This is the continuity-safe default for mid-lifecycle registration. The
11488    /// subscriber adopts the live desired state first, delivers the new owner's
11489    /// matching records at retained block `C` as owner catch-up, then delivers
11490    /// `C + 1` through activation as global canonical catch-up over the complete
11491    /// handler union. No discovery gap opens, and every effect after `C` enters
11492    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
11493    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
11494    /// pre-ingestion bootstrap. Use
11495    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
11496    /// for an explicit replay of one retained block or
11497    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
11498    /// out of backfill entirely.
11499    ///
11500    /// Subscriber registration commits before runtime routing is installed. If
11501    /// the subscriber operation fails or is cancelled, the runtime remains
11502    /// unchanged.
11503    ///
11504    /// # Errors
11505    ///
11506    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
11507    /// registered or the subscriber rejects/does not support the required
11508    /// owner update or coordinated catch-up.
11509    pub async fn register_handler(
11510        &mut self,
11511        handler: Arc<dyn ReactiveHandler<N>>,
11512    ) -> Result<(), ReactiveEngineRegisterError> {
11513        let backfill = self
11514            .runtime
11515            .last_canonical_block()
11516            .filter(|retained| {
11517                self.runtime.journal.iter().any(|entry| {
11518                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
11519                })
11520            })
11521            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
11522            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
11523        self.register_handler_inner(handler, backfill).await
11524    }
11525
11526    /// Register a handler and replay its matching logs at one exact retained
11527    /// canonical block.
11528    ///
11529    /// Owner-only effects are appended to that block's existing rollback
11530    /// journal entry. Consequently this method accepts only a bounded
11531    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
11532    /// anchor all identify the same journaled block. Wider/deeper recovery must
11533    /// use ordinary global canonical ingestion (for example startup catch-up),
11534    /// where every handler sees the records and the runtime advances coverage.
11535    ///
11536    /// If subscriber registration fails or is cancelled, the runtime remains
11537    /// unchanged.
11538    ///
11539    /// # Errors
11540    ///
11541    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
11542    /// registered, the requested backfill is not exactly one hash-certified
11543    /// retained journal block, or the subscriber update fails.
11544    pub async fn register_handler_with_backfill(
11545        &mut self,
11546        handler: Arc<dyn ReactiveHandler<N>>,
11547        backfill: SubscriberBackfill,
11548    ) -> Result<(), ReactiveEngineRegisterError> {
11549        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
11550            .await
11551    }
11552
11553    /// Register a handler without any log backfill — only logs delivered after
11554    /// its live subscription starts are routed to it.
11555    ///
11556    /// If subscriber registration fails or is cancelled, the runtime remains
11557    /// unchanged.
11558    ///
11559    /// # Errors
11560    ///
11561    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
11562    /// registered or the subscriber cannot commit the owner update.
11563    pub async fn register_handler_live_only(
11564        &mut self,
11565        handler: Arc<dyn ReactiveHandler<N>>,
11566    ) -> Result<(), ReactiveEngineRegisterError> {
11567        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
11568            .await
11569    }
11570
11571    async fn register_handler_inner(
11572        &mut self,
11573        handler: Arc<dyn ReactiveHandler<N>>,
11574        catchup: HandlerRegistrationCatchup,
11575    ) -> Result<(), ReactiveEngineRegisterError> {
11576        let id = handler.id();
11577        if self.runtime.contains_handler(&id) {
11578            return Err(RegisterError::DuplicateHandler(id).into());
11579        }
11580        let interests = handler.interests();
11581
11582        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
11583            let retained_anchor = backfill.retained_anchor().copied();
11584            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
11585                backfill.start_block() == anchor.number
11586                    && backfill.end_block() == Some(anchor.number)
11587                    && self.runtime.journal.iter().any(|entry| {
11588                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
11589                    })
11590            });
11591            if !is_exact_retained_block {
11592                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
11593                    start_block: backfill.start_block(),
11594                    end_block: backfill.end_block(),
11595                    retained_anchor,
11596                });
11597            }
11598        }
11599
11600        let subscribed = match catchup {
11601            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
11602                self.subscriber
11603                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
11604                    .await
11605            }
11606            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
11607                self.subscriber
11608                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
11609                    .await
11610            }
11611            HandlerRegistrationCatchup::LiveOnly => {
11612                self.subscriber
11613                    .add_interest_owner(id.clone(), &interests)
11614                    .await
11615            }
11616        };
11617        if let Err(error) = subscribed {
11618            return Err(error.into());
11619        }
11620
11621        // `&mut self` excludes concurrent registry mutation between the
11622        // duplicate preflight and this commit. Registration is deliberately
11623        // subscriber-first: cancelling the awaited operation cannot leave a
11624        // runtime handler active without committed subscriber interests.
11625        self.runtime
11626            .registry
11627            .insert_handler_prepared(id, handler, interests);
11628        Ok(())
11629    }
11630
11631    /// Register every handler currently in the runtime registry as a subscriber
11632    /// interest owner.
11633    ///
11634    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
11635    /// before ingestion starts, or for reattaching an already-aligned durable
11636    /// subscriber whose exact owner state was restored independently. Each
11637    /// handler becomes its own owner through one exact bulk replacement;
11638    /// crash-stale owners and unowned/base interests are removed.
11639    ///
11640    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
11641    /// potentially stale subscriber after the runtime has processed canonical
11642    /// state: use
11643    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
11644    /// which exact-replaces the owner set and closes continuity from the
11645    /// restored runtime position.
11646    ///
11647    /// The complete exact set commits through one subscriber operation; an
11648    /// error or cancellation leaves the previously committed topology
11649    /// authoritative.
11650    ///
11651    /// # Errors
11652    ///
11653    /// Returns [`SubscriberError`] when the subscriber cannot atomically
11654    /// replace the complete owner topology.
11655    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
11656        let owners = self
11657            .runtime
11658            .handler_ids()
11659            .into_iter()
11660            .map(|id| {
11661                let interests = self
11662                    .runtime
11663                    .handler_interests(&id)
11664                    .map(<[ReactiveInterest<N>]>::to_vec)
11665                    .unwrap_or_default();
11666                (id, interests)
11667            })
11668            .collect();
11669        self.subscriber.replace_interest_owners(owners).await
11670    }
11671
11672    /// Rebuild subscriber owner state from a runtime that already embodies a
11673    /// canonical checkpoint.
11674    ///
11675    /// The runtime registry is authoritative: the subscriber must atomically
11676    /// replace its complete owner set, removing crash-stale owners as well as
11677    /// adding the current ones. Log catch-up is routed globally through normal
11678    /// canonical ingestion and begins strictly at `C + 1`, where
11679    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
11680    /// cache already contains every effect through `C`. The exact number/hash
11681    /// identity of `C` remains attached as a retained baseline and must be
11682    /// validated by the subscriber before it exposes post-baseline records.
11683    /// Global routing is essential: startup catch-up effects enter the ordinary
11684    /// canonical journal and can be rolled back if the certified branch later
11685    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
11686    /// handler addition.
11687    ///
11688    /// A runtime without a canonical position must use
11689    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
11690    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
11691    /// The replacement is one subscriber commit boundary: errors and
11692    /// cancellation leave the previous topology authoritative.
11693    ///
11694    /// # Errors
11695    ///
11696    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
11697    /// exists or no exclusive successor can be represented, and otherwise
11698    /// propagates subscriber validation, transport, or atomic-commit failures.
11699    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
11700        let baseline =
11701            self.runtime
11702                .last_canonical_block()
11703                .ok_or(SubscriberError::InvalidConfig(
11704                    "cannot continuity-sync handlers before a canonical runtime position exists",
11705                ))?;
11706        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
11707        let owners = self
11708            .runtime
11709            .handler_ids()
11710            .into_iter()
11711            .map(|id| {
11712                let interests = self
11713                    .runtime
11714                    .handler_interests(&id)
11715                    .map(<[ReactiveInterest<N>]>::to_vec)
11716                    .unwrap_or_default();
11717                (id, interests)
11718            })
11719            .collect();
11720        self.subscriber
11721            .replace_interest_owners_with_global_backfill(owners, backfill)
11722            .await
11723    }
11724
11725    /// Unregister a handler from both the subscriber and runtime.
11726    ///
11727    /// Subscriber interests are removed first so no new live records are routed
11728    /// to a handler after it has left the runtime registry. Returns the removed
11729    /// handler when the id was registered. If subscriber removal fails or is
11730    /// cancelled, runtime routing remains installed.
11731    ///
11732    /// This is the routing/transport half of dropping an adapter. State the
11733    /// handler accumulated is deliberately left in place; the complete teardown
11734    /// for a pool or adapter that will not return is:
11735    ///
11736    /// ```text
11737    /// engine.unregister_handler(&id).await?;
11738    /// for request_id in handler_request_ids {
11739    ///     // Drop only this handler generation's queued repair work.
11740    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
11741    /// }
11742    /// for address in exclusively_owned_addresses {
11743    ///     // Shared accounts require caller-side owner reference counting.
11744    ///     engine.runtime_mut().untrack_account(address);
11745    /// }
11746    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
11747    /// ```
11748    ///
11749    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
11750    /// runtime-global and are never touched by handler removal.
11751    ///
11752    /// # Errors
11753    ///
11754    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
11755    /// removal. In that case runtime routing remains installed.
11756    pub async fn unregister_handler(
11757        &mut self,
11758        id: &HandlerId,
11759    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
11760        self.subscriber.remove_interest_owner(id).await?;
11761        Ok(self.runtime.unregister_handler(id))
11762    }
11763}
11764
11765/// Alloy-backed event subscriber.
11766///
11767/// The default transport slice drives Alloy pubsub subscriptions for logs,
11768/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
11769/// transport remains available behind the opt-in `reactive-polling` feature.
11770/// Pubsub streams reconnect automatically after termination, and log
11771/// subscriptions are backfilled from the last seen block. Owner-scoped log
11772/// additions can request backfill from an explicit block anchor. Full pending
11773/// transaction hydration and full block bodies remain explicit follow-up work.
11774///
11775/// Historical log fetching is deliberately a bounded live-subscriber aid, not
11776/// a high-volume indexer: each filter/window is issued as one complete-range
11777/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
11778/// an oversized decoded response, but the subscriber does not adaptively split
11779/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
11780/// registration and reconnect windows modest; use an indexing source such as
11781/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
11782///
11783/// With no registered interests, [`EventSubscriber::next_batch`] returns
11784/// `Ok(None)`.
11785pub struct AlloySubscriber<P, N: Network = Ethereum> {
11786    provider: P,
11787    /// Stable identity for the provider session used by Flashblocks and every
11788    /// follow-up pending-state read.
11789    provider_ref: Option<ProviderRef>,
11790    /// Optional provider dedicated to canonical log-context verification.
11791    /// Keeping this separate prevents a high-volume pubsub connection from
11792    /// starving its own verification requests behind log notifications.
11793    log_verification_provider: Option<P>,
11794    /// Provider chain identity, resolved once before any record can escape.
11795    chain_id: Option<u64>,
11796    mode: SubscriberMode,
11797    config: SubscriberConfig,
11798    base_interests: Vec<ReactiveInterest<N>>,
11799    owned_interests: Vec<OwnedSubscriberInterests<N>>,
11800    next_owner_epoch: u64,
11801    interests: Vec<ReactiveInterest<N>>,
11802    /// Stable source id per distinct provider-facing log filter. Ids key
11803    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
11804    /// their anchors pruned) when no planned stream references the filter, so
11805    /// long-lived owner churn cannot grow this map unboundedly.
11806    log_source_ids: HashMap<Filter, usize>,
11807    next_log_source_id: usize,
11808    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
11809    /// Successfully connected sources whose subscribe-then-backfill step has
11810    /// not committed yet. Installation happens before the backfill await, so a
11811    /// cancelled reconcile keeps the live stream and retries only the missing
11812    /// historical window.
11813    pending_source_backfills: VecDeque<SubscriberStreamSource>,
11814    /// Set when interest bookkeeping changed since the last successful stream
11815    /// reconcile, so steady-state polling skips the desired-vs-live diff.
11816    sources_dirty: bool,
11817    /// Conservative generation of desired/live stream topology. Successful
11818    /// owner progress is activatable only against the same clean revision.
11819    stream_revision: u64,
11820    state: AlloySubscriberState<N>,
11821    pending_records: VecDeque<SubscriberInputRecord<N>>,
11822    pending_chain_controls: VecDeque<ChainControl>,
11823    /// Owner copies of live records consumed during an in-flight reconcile.
11824    /// These remain hidden from subscriber output until the owning reconcile
11825    /// commits and survive cancellation so subscribe-first adoption cannot
11826    /// lose an event at an await boundary.
11827    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
11828    /// Sticky fail-closed capacity error. Once an event could not be retained,
11829    /// only a full replacement registration can establish a new baseline.
11830    resource_error: Option<String>,
11831    last_seen_log_blocks: HashMap<usize, u64>,
11832    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
11833    verified_log_block_order: VecDeque<(u64, B256)>,
11834    recent_input_refs: VecDeque<InputRef>,
11835    recent_input_ref_set: HashSet<InputRef>,
11836    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
11837    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
11838    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
11839    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
11840    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
11841    flashblocks_by_hash: HashMap<B256, FlashblockRef>,
11842    flashblock_hash_order: VecDeque<B256>,
11843    unmatched_pending_logs: VecDeque<(usize, Log)>,
11844    latest_preconfirmation: Option<FlashblockRef>,
11845    preconfirmed_seen_logs: HashSet<(B256, u64)>,
11846    _network: PhantomData<N>,
11847}
11848
11849struct OwnedSubscriberInterests<N: Network = Ethereum> {
11850    owner: HandlerId,
11851    interests: Vec<ReactiveInterest<N>>,
11852    epoch: Option<SubscriberOwnerEpoch>,
11853    state: SubscriberOwnerState,
11854    baseline: Option<BlockRef>,
11855    progress: Option<SubscriberOwnerProgress>,
11856    progress_stream_revision: Option<u64>,
11857}
11858
11859#[derive(Clone)]
11860struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
11861    epoch: SubscriberOwnerEpoch,
11862    interests: Vec<ReactiveInterest<N>>,
11863    retained: BlockRef,
11864    from_block: u64,
11865}
11866
11867struct SubscriberOwnerCatchup {
11868    logs: Vec<Log>,
11869    certified: BlockRef,
11870}
11871
11872#[derive(Clone, Copy)]
11873struct SubscriberOwnerCatchupOptions {
11874    target_preverified: bool,
11875    max_logs: usize,
11876    max_log_bytes: usize,
11877    max_requests_in_flight: usize,
11878}
11879
11880struct SubscriberOwnerReconcileFilter {
11881    filter: Filter,
11882    from_block: u64,
11883}
11884
11885struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
11886    record: ReactiveInputRecord<N>,
11887    owners: Vec<SubscriberOwnerEpoch>,
11888}
11889
11890const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
11891
11892struct QueuedSubscriberBackfill {
11893    /// `None` means global canonical catch-up; `Some` is compatibility
11894    /// owner-only catch-up for true mid-lifecycle additions.
11895    owner: Option<HandlerId>,
11896    epoch: Option<SubscriberOwnerEpoch>,
11897    /// Complete logical filter set for one certified, globally ordered window.
11898    filters: Vec<Filter>,
11899    backfill: SubscriberBackfill,
11900}
11901
11902/// Best-effort installation of rustls' `ring` crypto provider as the process
11903/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
11904/// "no process-level CryptoProvider available". Runs at most once and ignores the
11905/// error if a default provider is already installed (the host app may have set
11906/// its own).
11907#[cfg(feature = "reactive-ws")]
11908fn ensure_ring_crypto_provider() {
11909    use std::sync::Once;
11910    static INSTALL: Once = Once::new();
11911    INSTALL.call_once(|| {
11912        let _ = rustls::crypto::ring::default_provider().install_default();
11913    });
11914}
11915
11916impl<P, N: Network> AlloySubscriber<P, N> {
11917    /// Create a new Alloy subscriber.
11918    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
11919        #[cfg(feature = "reactive-ws")]
11920        ensure_ring_crypto_provider();
11921        Self {
11922            provider,
11923            provider_ref: None,
11924            log_verification_provider: None,
11925            chain_id: None,
11926            mode,
11927            config,
11928            base_interests: Vec::new(),
11929            owned_interests: Vec::new(),
11930            next_owner_epoch: 0,
11931            interests: Vec::new(),
11932            log_source_ids: HashMap::new(),
11933            next_log_source_id: 0,
11934            pending_backfills: VecDeque::new(),
11935            pending_source_backfills: VecDeque::new(),
11936            sources_dirty: true,
11937            stream_revision: 0,
11938            state: AlloySubscriberState::Uninitialized,
11939            pending_records: VecDeque::new(),
11940            pending_chain_controls: VecDeque::new(),
11941            pending_reconcile_owner_records: VecDeque::new(),
11942            resource_error: None,
11943            last_seen_log_blocks: HashMap::new(),
11944            verified_log_blocks: HashMap::new(),
11945            verified_log_block_order: VecDeque::new(),
11946            recent_input_refs: VecDeque::new(),
11947            recent_input_ref_set: HashSet::new(),
11948            recent_owner_input_refs: HashMap::new(),
11949            recent_owner_input_ref_sets: HashMap::new(),
11950            recent_compat_owner_input_refs: HashMap::new(),
11951            recent_compat_owner_input_ref_sets: HashMap::new(),
11952            base_flashblock_header: None,
11953            flashblocks_by_hash: HashMap::new(),
11954            flashblock_hash_order: VecDeque::new(),
11955            unmatched_pending_logs: VecDeque::new(),
11956            latest_preconfirmation: None,
11957            preconfirmed_seen_logs: HashSet::new(),
11958            _network: PhantomData,
11959        }
11960    }
11961
11962    /// Borrow the provider.
11963    pub fn provider(&self) -> &P {
11964        &self.provider
11965    }
11966
11967    /// Bind this subscriber to the concrete provider lease that supplies
11968    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
11969    /// with the single `flashblocks = true` flag.
11970    #[must_use]
11971    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
11972        self.provider_ref = Some(provider);
11973        self
11974    }
11975
11976    /// Use a separate provider for canonical log-context verification.
11977    ///
11978    /// This is recommended with
11979    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
11980    /// deployments. The provider must target the same chain; every fetched
11981    /// block is still checked against the log's number, hash, and timestamp.
11982    #[must_use]
11983    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
11984        self.log_verification_provider = Some(provider);
11985        self
11986    }
11987
11988    /// Subscriber mode.
11989    pub fn mode(&self) -> SubscriberMode {
11990        self.mode
11991    }
11992
11993    /// Subscriber config.
11994    pub fn config(&self) -> &SubscriberConfig {
11995        &self.config
11996    }
11997
11998    /// Registered interests across base and owner-scoped registrations.
11999    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
12000        &self.interests
12001    }
12002
12003    /// Stage a fresh, epoch-scoped interest owner without making its inputs
12004    /// canonically routable yet.
12005    ///
12006    /// The returned token is required by every later lifecycle operation. A
12007    /// staged owner participates in provider subscription planning immediately,
12008    /// while its matching input remains owner-scoped until
12009    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
12010    /// Post-block owners require hash-certified
12011    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
12012    /// the current clean stream revision before activation.
12013    ///
12014    /// # Errors
12015    ///
12016    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12017    /// duplicate owners, unsupported post-block interests, unsupported
12018    /// transport interests, block-number overflow, or epoch exhaustion.
12019    pub fn stage_interest_owner(
12020        &mut self,
12021        owner: HandlerId,
12022        interests: &[ReactiveInterest<N>],
12023        start: SubscriberOwnerStart,
12024    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12025        validate_subscriber_config(&self.config)?;
12026        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12027            && interests
12028                .iter()
12029                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12030        {
12031            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12032        }
12033        if self
12034            .owned_interests
12035            .iter()
12036            .any(|entry| entry.owner == owner)
12037        {
12038            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12039        }
12040
12041        let mut next_owned = self.clone_owned_interests();
12042        next_owned.push(OwnedSubscriberInterests {
12043            owner: owner.clone(),
12044            interests: interests.to_vec(),
12045            epoch: None,
12046            state: SubscriberOwnerState::Staged,
12047            baseline: None,
12048            progress: None,
12049            progress_stream_revision: None,
12050        });
12051        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12052        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12053
12054        let baseline = match start {
12055            SubscriberOwnerStart::Live => None,
12056            SubscriberOwnerStart::PostBlock(block) => {
12057                block
12058                    .number
12059                    .checked_add(1)
12060                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12061                Some(block)
12062            }
12063        };
12064        let sequence = self
12065            .next_owner_epoch
12066            .checked_add(1)
12067            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12068        let epoch = SubscriberOwnerEpoch {
12069            owner: owner.clone(),
12070            sequence,
12071        };
12072
12073        self.next_owner_epoch = sequence;
12074        let entry = next_owned
12075            .last_mut()
12076            .expect("staged owner was appended during preflight");
12077        entry.epoch = Some(epoch.clone());
12078        entry.baseline = baseline;
12079        self.owned_interests = next_owned;
12080        self.interests = next_registered;
12081        self.sources_dirty = true;
12082
12083        Ok(epoch)
12084    }
12085
12086    /// Stage replacement interests for one currently active logical owner.
12087    ///
12088    /// The active epoch remains canonical while the replacement reconciles.
12089    /// Commit both epochs atomically with
12090    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
12091    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
12092    ///
12093    /// # Errors
12094    ///
12095    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12096    /// missing/non-unique active owner state, unsupported post-block interests,
12097    /// unsupported transport interests, block-number overflow, or epoch
12098    /// exhaustion.
12099    pub fn stage_interest_owner_replacement(
12100        &mut self,
12101        owner: HandlerId,
12102        interests: &[ReactiveInterest<N>],
12103        start: SubscriberOwnerStart,
12104    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12105        validate_subscriber_config(&self.config)?;
12106        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12107            && interests
12108                .iter()
12109                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12110        {
12111            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12112        }
12113        let active_count = self
12114            .owned_interests
12115            .iter()
12116            .filter(|entry| {
12117                entry.owner == owner
12118                    && entry.state == SubscriberOwnerState::Active
12119                    && entry.epoch.is_some()
12120            })
12121            .count();
12122        if active_count != 1
12123            || self
12124                .owned_interests
12125                .iter()
12126                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
12127        {
12128            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12129        }
12130
12131        let mut next_owned = self.clone_owned_interests();
12132        next_owned.push(OwnedSubscriberInterests {
12133            owner: owner.clone(),
12134            interests: interests.to_vec(),
12135            epoch: None,
12136            state: SubscriberOwnerState::Staged,
12137            baseline: None,
12138            progress: None,
12139            progress_stream_revision: None,
12140        });
12141        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12142        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12143
12144        let baseline = match start {
12145            SubscriberOwnerStart::Live => None,
12146            SubscriberOwnerStart::PostBlock(block) => {
12147                block
12148                    .number
12149                    .checked_add(1)
12150                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12151                Some(block)
12152            }
12153        };
12154        let sequence = self
12155            .next_owner_epoch
12156            .checked_add(1)
12157            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12158        let epoch = SubscriberOwnerEpoch {
12159            owner: owner.clone(),
12160            sequence,
12161        };
12162
12163        self.next_owner_epoch = sequence;
12164        let entry = next_owned
12165            .last_mut()
12166            .expect("staged replacement owner was appended during preflight");
12167        entry.epoch = Some(epoch.clone());
12168        entry.baseline = baseline;
12169        self.owned_interests = next_owned;
12170        self.interests = next_registered;
12171        self.sources_dirty = true;
12172        Ok(epoch)
12173    }
12174
12175    /// Current transaction state for an exact owner epoch.
12176    pub fn interest_owner_state(
12177        &self,
12178        epoch: &SubscriberOwnerEpoch,
12179    ) -> Option<SubscriberOwnerState> {
12180        self.owned_interests
12181            .iter()
12182            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12183            .map(|entry| entry.state)
12184    }
12185
12186    /// Latest hash-certified reconcile progress for an exact owner epoch.
12187    pub fn interest_owner_progress(
12188        &self,
12189        epoch: &SubscriberOwnerEpoch,
12190    ) -> Option<&SubscriberOwnerProgress> {
12191        self.owned_interests
12192            .iter()
12193            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12194            .and_then(|entry| entry.progress.as_ref())
12195    }
12196
12197    /// Make a staged owner canonical after its actor-side installation commits.
12198    ///
12199    /// Returns `false` for stale tokens and owners not currently staged.
12200    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12201        let stream_revision = self.stream_revision;
12202        let sources_dirty = self.sources_dirty;
12203        let Some(entry) = self
12204            .owned_interests
12205            .iter_mut()
12206            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12207        else {
12208            return false;
12209        };
12210        if entry.state != SubscriberOwnerState::Staged
12211            || (entry.baseline.is_some()
12212                && (entry.progress.is_none()
12213                    || entry.progress_stream_revision != Some(stream_revision)
12214                    || sources_dirty))
12215        {
12216            return false;
12217        }
12218        entry.state = SubscriberOwnerState::Active;
12219        true
12220    }
12221
12222    /// Atomically replace one active owner epoch with one reconciled staged epoch.
12223    pub fn commit_interest_owner_replacement(
12224        &mut self,
12225        active: &SubscriberOwnerEpoch,
12226        replacement: &SubscriberOwnerEpoch,
12227    ) -> bool {
12228        let Some(active_index) = self
12229            .owned_interests
12230            .iter()
12231            .position(|entry| entry.epoch.as_ref() == Some(active))
12232        else {
12233            return false;
12234        };
12235        let Some(replacement_index) = self
12236            .owned_interests
12237            .iter()
12238            .position(|entry| entry.epoch.as_ref() == Some(replacement))
12239        else {
12240            return false;
12241        };
12242        if active_index == replacement_index
12243            || active.owner() != replacement.owner()
12244            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
12245            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
12246            || (self.owned_interests[replacement_index].baseline.is_some()
12247                && (self.owned_interests[replacement_index].progress.is_none()
12248                    || self.owned_interests[replacement_index].progress_stream_revision
12249                        != Some(self.stream_revision)
12250                    || self.sources_dirty))
12251        {
12252            return false;
12253        }
12254
12255        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
12256        self.owned_interests.remove(active_index);
12257        self.purge_owner_epoch(active);
12258        self.rebuild_registered_interests();
12259        self.retire_unreferenced_filters();
12260        self.sources_dirty = true;
12261        true
12262    }
12263
12264    /// Prepare an exact active owner for removal without changing desired
12265    /// interests, streams, anchors, or queued canonical input.
12266    ///
12267    /// The caller establishes its delivery fence after this transition. Use
12268    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
12269    /// on actor-side failure, or
12270    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
12271    /// once canonical routing has been removed.
12272    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12273        let Some(entry) = self
12274            .owned_interests
12275            .iter_mut()
12276            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12277        else {
12278            return false;
12279        };
12280        if entry.state != SubscriberOwnerState::Active {
12281            return false;
12282        }
12283        entry.state = SubscriberOwnerState::Removing;
12284        true
12285    }
12286
12287    /// Finalize a previously prepared exact owner removal.
12288    ///
12289    /// Returns the removed interests, or `None` for stale tokens and owners not
12290    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
12291    /// is therefore idempotent.
12292    pub fn finalize_interest_owner_removal(
12293        &mut self,
12294        epoch: &SubscriberOwnerEpoch,
12295    ) -> Option<Vec<ReactiveInterest<N>>> {
12296        let index = self.owned_interests.iter().position(|entry| {
12297            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
12298        })?;
12299        let removed = self.owned_interests.remove(index).interests;
12300        self.purge_owner_epoch(epoch);
12301        self.rebuild_registered_interests();
12302        self.retire_unreferenced_filters();
12303        self.sources_dirty = true;
12304        Some(removed)
12305    }
12306
12307    /// Abort an epoch-scoped owner lifecycle operation.
12308    ///
12309    /// A staged owner is removed completely. A prepared removal is restored to
12310    /// active. Active and unknown epochs are unchanged. Repeating the same
12311    /// abort is therefore safe and returns `false` after the first effect.
12312    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12313        let Some(index) = self
12314            .owned_interests
12315            .iter()
12316            .position(|entry| entry.epoch.as_ref() == Some(epoch))
12317        else {
12318            return false;
12319        };
12320        match self.owned_interests[index].state {
12321            SubscriberOwnerState::Staged => {
12322                self.owned_interests.remove(index);
12323                self.purge_owner_epoch(epoch);
12324                self.rebuild_registered_interests();
12325                self.retire_unreferenced_filters();
12326                self.sources_dirty = true;
12327                true
12328            }
12329            SubscriberOwnerState::Removing => {
12330                self.owned_interests[index].state = SubscriberOwnerState::Active;
12331                true
12332            }
12333            SubscriberOwnerState::Active => false,
12334        }
12335    }
12336
12337    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
12338        self.pending_backfills
12339            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
12340        self.pending_records
12341            .retain_mut(|pending| match &mut pending.scope {
12342                SubscriberInputScope::Canonical { owners }
12343                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
12344                    owners.retain(|owner| owner != epoch);
12345                    true
12346                }
12347                SubscriberInputScope::OwnerOnly { owners } => {
12348                    owners.retain(|owner| owner != epoch);
12349                    !owners.is_empty()
12350                }
12351                SubscriberInputScope::OwnerOnlyHandlers { .. }
12352                | SubscriberInputScope::Preconfirmed => true,
12353            });
12354        self.pending_reconcile_owner_records.retain_mut(|pending| {
12355            pending.owners.retain(|owner| owner != epoch);
12356            !pending.owners.is_empty()
12357        });
12358        self.recent_owner_input_refs.remove(epoch);
12359        self.recent_owner_input_ref_sets.remove(epoch);
12360    }
12361
12362    /// Atomically add or replace several owners while preserving unrelated ones.
12363    ///
12364    /// # Errors
12365    ///
12366    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12367    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
12368    /// exhaustion. No owner state changes on error.
12369    pub fn upsert_interest_owners(
12370        &mut self,
12371        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12372    ) -> Result<(), SubscriberError> {
12373        self.upsert_interest_owners_inner(owners, None)
12374    }
12375
12376    /// Atomically add or replace several owners and queue one common backfill
12377    /// policy for every log interest while preserving unrelated owners.
12378    ///
12379    /// # Errors
12380    ///
12381    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12382    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
12383    /// exhaustion. No owner or backfill state changes on error.
12384    pub fn upsert_interest_owners_with_backfill(
12385        &mut self,
12386        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12387        backfill: SubscriberBackfill,
12388    ) -> Result<(), SubscriberError> {
12389        self.upsert_interest_owners_inner(owners, Some(backfill))
12390    }
12391
12392    fn upsert_interest_owners_inner(
12393        &mut self,
12394        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12395        explicit_backfill: Option<SubscriberBackfill>,
12396    ) -> Result<(), SubscriberError> {
12397        validate_subscriber_config(&self.config)?;
12398        let mut seen = HashSet::with_capacity(owners.len());
12399        let mut next_owned = self.clone_owned_interests();
12400        for (owner, interests) in &owners {
12401            if !seen.insert(owner.clone()) {
12402                return Err(SubscriberError::InvalidConfig(
12403                    "bulk owner upsert contains a duplicate owner",
12404                ));
12405            }
12406            if self
12407                .owned_interests
12408                .iter()
12409                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
12410            {
12411                return Err(SubscriberError::InvalidConfig(
12412                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
12413                ));
12414            }
12415            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
12416                entry.interests = interests.clone();
12417                entry.state = SubscriberOwnerState::Active;
12418                entry.baseline = None;
12419                entry.progress = None;
12420                entry.progress_stream_revision = None;
12421            } else {
12422                next_owned.push(OwnedSubscriberInterests {
12423                    owner: owner.clone(),
12424                    interests: interests.clone(),
12425                    epoch: None,
12426                    state: SubscriberOwnerState::Active,
12427                    baseline: None,
12428                    progress: None,
12429                    progress_stream_revision: None,
12430                });
12431            }
12432        }
12433        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12434        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12435
12436        // Build every owner's replacement queue before the first mutation.
12437        // Besides keeping capacity failure atomic, this preserves continuity
12438        // for changed filter shapes when the caller did not provide a common
12439        // open-ended backfill that already covers the old delivery anchor.
12440        let mut replacement_backfills = Vec::new();
12441        for (owner, interests) in &owners {
12442            let previous_filters: Vec<Filter> = self
12443                .owner_interests(owner)
12444                .map(log_filters)
12445                .unwrap_or_default();
12446            let continuity_anchor = previous_filters
12447                .iter()
12448                .filter_map(|filter| self.log_anchor(filter))
12449                .min();
12450            let filters = log_filters(interests);
12451            if let Some(backfill) = explicit_backfill
12452                && !filters.is_empty()
12453            {
12454                replacement_backfills.push(QueuedSubscriberBackfill {
12455                    owner: Some(owner.clone()),
12456                    epoch: None,
12457                    filters: filters.clone(),
12458                    backfill,
12459                });
12460            }
12461            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
12462                explicit.end_block().is_none()
12463                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
12464            });
12465            let continuity_filters: Vec<_> = filters
12466                .into_iter()
12467                .filter(|filter| !previous_filters.contains(filter))
12468                .collect();
12469            if let Some(anchor) = continuity_anchor
12470                && !continuity_filters.is_empty()
12471                && !explicit_covers
12472            {
12473                replacement_backfills.push(QueuedSubscriberBackfill {
12474                    owner: Some(owner.clone()),
12475                    epoch: None,
12476                    filters: continuity_filters,
12477                    backfill: SubscriberBackfill::from_block(anchor),
12478                });
12479            }
12480        }
12481
12482        let retained_backfills = self
12483            .pending_backfills
12484            .iter()
12485            .filter(|queued| {
12486                queued
12487                    .owner
12488                    .as_ref()
12489                    .is_none_or(|owner| !seen.contains(owner))
12490            })
12491            .map(|queued| queued.filters.len())
12492            .sum::<usize>();
12493        let replacement_units = replacement_backfills
12494            .iter()
12495            .map(|queued| queued.filters.len())
12496            .sum::<usize>();
12497        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
12498        {
12499            return Err(SubscriberError::ResourceExhausted(format!(
12500                "bulk owner update would queue more than {} lazy backfills",
12501                self.config.max_pending_backfills
12502            )));
12503        }
12504
12505        // All validation and capacity checks are complete. The remaining
12506        // assignments have no failure or cancellation point, so topology and
12507        // historical work become authoritative as one local commit.
12508        self.owned_interests = next_owned;
12509        self.interests = next_registered;
12510        for owner in &seen {
12511            self.recent_compat_owner_input_refs.remove(owner);
12512            self.recent_compat_owner_input_ref_sets.remove(owner);
12513        }
12514        self.retire_unreferenced_filters();
12515        self.sources_dirty = true;
12516        self.pending_backfills.retain(|queued| {
12517            queued
12518                .owner
12519                .as_ref()
12520                .is_none_or(|owner| !seen.contains(owner))
12521        });
12522        self.pending_backfills.extend(replacement_backfills);
12523        Ok(())
12524    }
12525
12526    /// Atomically replace every compatibility owner without requesting
12527    /// historical delivery.
12528    ///
12529    /// # Errors
12530    ///
12531    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12532    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
12533    /// The previous topology remains authoritative on error.
12534    pub fn replace_interest_owners(
12535        &mut self,
12536        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12537    ) -> Result<(), SubscriberError> {
12538        self.replace_interest_owners_inner(owners, None)
12539    }
12540
12541    /// Atomically replace every compatibility owner and queue one global
12542    /// post-baseline backfill for the resulting union of log interests.
12543    ///
12544    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
12545    /// be mixed with this compatibility replacement because silently deleting
12546    /// an in-flight epoch would violate its activation transaction.
12547    ///
12548    /// # Errors
12549    ///
12550    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12551    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
12552    /// exhaustion. The previous topology remains authoritative on error.
12553    pub fn replace_interest_owners_with_global_backfill(
12554        &mut self,
12555        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12556        backfill: SubscriberBackfill,
12557    ) -> Result<(), SubscriberError> {
12558        self.replace_interest_owners_inner(owners, Some(backfill))
12559    }
12560
12561    fn replace_interest_owners_inner(
12562        &mut self,
12563        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12564        backfill: Option<SubscriberBackfill>,
12565    ) -> Result<(), SubscriberError> {
12566        validate_subscriber_config(&self.config)?;
12567        if self
12568            .owned_interests
12569            .iter()
12570            .any(|entry| entry.epoch.is_some())
12571        {
12572            return Err(SubscriberError::InvalidConfig(
12573                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
12574            ));
12575        }
12576
12577        let mut seen = HashSet::with_capacity(owners.len());
12578        let mut next_owned = Vec::with_capacity(owners.len());
12579        for (owner, interests) in owners {
12580            if !seen.insert(owner.clone()) {
12581                return Err(SubscriberError::InvalidConfig(
12582                    "owner replacement contains a duplicate owner",
12583                ));
12584            }
12585            next_owned.push(OwnedSubscriberInterests {
12586                owner,
12587                interests,
12588                epoch: None,
12589                state: SubscriberOwnerState::Active,
12590                baseline: None,
12591                progress: None,
12592                progress_stream_revision: None,
12593            });
12594        }
12595        let next_registered = aggregate_interests(&[], &next_owned);
12596        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12597        let mut filters = log_filters(&next_registered);
12598        let mut unique_filters = Vec::with_capacity(filters.len());
12599        for filter in filters.drain(..) {
12600            if !unique_filters.contains(&filter) {
12601                unique_filters.push(filter);
12602            }
12603        }
12604        let replacement_backfills: VecDeque<_> = match backfill {
12605            Some(backfill) if !unique_filters.is_empty() => {
12606                VecDeque::from([QueuedSubscriberBackfill {
12607                    owner: None,
12608                    epoch: None,
12609                    filters: unique_filters,
12610                    backfill,
12611                }])
12612            }
12613            Some(_) | None => VecDeque::new(),
12614        };
12615        let replacement_units = replacement_backfills
12616            .iter()
12617            .map(|queued| queued.filters.len())
12618            .sum::<usize>();
12619        if replacement_units > self.config.max_pending_backfills {
12620            return Err(SubscriberError::ResourceExhausted(format!(
12621                "owner replacement would queue more than {} lazy backfills",
12622                self.config.max_pending_backfills
12623            )));
12624        }
12625
12626        // No fallible work remains. The post-baseline range reconstructs every
12627        // delivery after the cache snapshot, so reset all stale delivery and
12628        // dedupe state from the prior topology before publishing the exact
12629        // replacement plus its global historical work.
12630        self.base_interests.clear();
12631        self.owned_interests = next_owned;
12632        self.interests = next_registered;
12633        self.reset_delivery_state();
12634        self.pending_backfills = replacement_backfills;
12635        self.state = AlloySubscriberState::Uninitialized;
12636        Ok(())
12637    }
12638
12639    /// Add or replace the interests owned by `owner`.
12640    ///
12641    /// This preserves unrelated owners, queued/pending records, recent dedupe
12642    /// state, and last-seen log anchors. The live transport is reconciled on the
12643    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
12644    /// be subscribed without rebuilding the whole subscriber object.
12645    ///
12646    /// Replacing an existing owner is continuity-safe: filters the owner
12647    /// already had keep their delivery anchors, and any changed or new filter
12648    /// shape is automatically backfilled from the owner's oldest prior anchor —
12649    /// growing a pool set on an established owner does not open a delivery gap
12650    /// for what the old subscription had already covered. A brand-new owner has
12651    /// no anchor to inherit; pass an explicit
12652    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
12653    /// anchor (or register through [`ReactiveEngine::register_handler`], which
12654    /// anchors to the runtime's last canonical block).
12655    ///
12656    /// # Errors
12657    ///
12658    /// Returns [`SubscriberError`] for invalid configuration, incompatible
12659    /// lifecycle state, unsupported interests, or continuity-backfill capacity
12660    /// exhaustion. The prior owner state remains authoritative on error.
12661    pub fn add_interest_owner(
12662        &mut self,
12663        owner: HandlerId,
12664        interests: &[ReactiveInterest<N>],
12665    ) -> Result<(), SubscriberError> {
12666        self.set_interest_owner(owner, interests, None)
12667    }
12668
12669    /// Add or replace owner interests and schedule log backfill for that owner.
12670    ///
12671    /// Backfill is queued only for log interests; block and pending transaction
12672    /// interests are live-only. Queued records can be delivered immediately;
12673    /// the subsequent provider stream is then caught up from the seeded
12674    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
12675    /// is closed end to end as long
12676    /// as `backfill` starts at (or before) the block the interest was
12677    /// discovered in. Continuity backfill for a replaced owner (see
12678    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
12679    /// unless this explicit backfill is open-ended and already starts at or
12680    /// below the owner's prior anchor.
12681    ///
12682    /// # Errors
12683    ///
12684    /// Returns [`SubscriberError`] for invalid configuration, incompatible
12685    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
12686    /// The prior owner state remains authoritative on error.
12687    pub fn add_interest_owner_with_backfill(
12688        &mut self,
12689        owner: HandlerId,
12690        interests: &[ReactiveInterest<N>],
12691        backfill: SubscriberBackfill,
12692    ) -> Result<(), SubscriberError> {
12693        self.set_interest_owner(owner, interests, Some(backfill))
12694    }
12695
12696    /// Add or replace one owner at retained canonical block `C`, then queue the
12697    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
12698    ///
12699    /// The new owner alone receives matching records from `C` so its effects
12700    /// attach to the runtime's existing journal entry. Every matching log from
12701    /// `C + 1` through the activation head is then delivered canonically over
12702    /// the complete interest union. [`Self::next_scoped_batch`] installs the
12703    /// desired live streams before draining either window, closing the
12704    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
12705    /// pending-transaction deliveries through this log backfill path, so a
12706    /// mixed interest topology is rejected rather than silently underfilled.
12707    ///
12708    /// # Errors
12709    ///
12710    /// Returns [`SubscriberError`] for invalid configuration, incompatible
12711    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
12712    /// resource exhaustion. The prior owner state remains authoritative on
12713    /// error.
12714    pub fn add_interest_owner_with_canonical_catchup(
12715        &mut self,
12716        owner: HandlerId,
12717        interests: &[ReactiveInterest<N>],
12718        retained: BlockRef,
12719    ) -> Result<(), SubscriberError> {
12720        validate_subscriber_config(&self.config)?;
12721        if self
12722            .owned_interests
12723            .iter()
12724            .any(|entry| entry.owner == owner && entry.epoch.is_some())
12725        {
12726            return Err(SubscriberError::InvalidConfig(
12727                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
12728            ));
12729        }
12730
12731        let mut next_owned = self.clone_owned_interests();
12732        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
12733            entry.interests = interests.to_vec();
12734            entry.state = SubscriberOwnerState::Active;
12735            entry.baseline = None;
12736            entry.progress = None;
12737            entry.progress_stream_revision = None;
12738            entry.epoch = None;
12739        } else {
12740            next_owned.push(OwnedSubscriberInterests {
12741                owner: owner.clone(),
12742                interests: interests.to_vec(),
12743                epoch: None,
12744                state: SubscriberOwnerState::Active,
12745                baseline: None,
12746                progress: None,
12747                progress_stream_revision: None,
12748            });
12749        }
12750        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12751        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12752        if next_registered
12753            .iter()
12754            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12755        {
12756            return Err(SubscriberError::Unsupported(
12757                "Alloy coordinated registration supports log-only interest topologies",
12758            ));
12759        }
12760
12761        let mut owner_filters = Vec::new();
12762        for filter in log_filters(interests) {
12763            if !owner_filters.contains(&filter) {
12764                owner_filters.push(filter);
12765            }
12766        }
12767        let mut global_filters = Vec::new();
12768        for filter in log_filters(&next_registered) {
12769            if !global_filters.contains(&filter) {
12770                global_filters.push(filter);
12771            }
12772        }
12773        let owner_backfill =
12774            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
12775        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
12776        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
12777        let retained_units = self
12778            .pending_backfills
12779            .iter()
12780            .filter(|queued| queued.owner.as_ref() != Some(&owner))
12781            .map(|queued| queued.filters.len())
12782            .sum::<usize>();
12783        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
12784            return Err(SubscriberError::ResourceExhausted(format!(
12785                "coordinated owner registration would queue more than {} lazy backfills",
12786                self.config.max_pending_backfills
12787            )));
12788        }
12789
12790        let mut replacement_backfills = VecDeque::new();
12791        if !owner_filters.is_empty() {
12792            replacement_backfills.push_back(QueuedSubscriberBackfill {
12793                owner: Some(owner.clone()),
12794                epoch: None,
12795                filters: owner_filters,
12796                backfill: owner_backfill,
12797            });
12798        }
12799        // Keep the global certification job even for an empty filter union: it
12800        // advances canonical coverage through a zero-event registration window.
12801        replacement_backfills.push_back(QueuedSubscriberBackfill {
12802            owner: None,
12803            epoch: None,
12804            filters: global_filters,
12805            backfill: global_backfill,
12806        });
12807
12808        // Every fallible preflight is complete. Publish topology and both
12809        // ordered windows as one synchronous local commit.
12810        self.owned_interests = next_owned;
12811        self.interests = next_registered;
12812        self.recent_compat_owner_input_refs.remove(&owner);
12813        self.recent_compat_owner_input_ref_sets.remove(&owner);
12814        self.pending_backfills
12815            .retain(|queued| queued.owner.as_ref() != Some(&owner));
12816        self.pending_backfills.extend(replacement_backfills);
12817        self.retire_unreferenced_filters();
12818        self.sources_dirty = true;
12819        Ok(())
12820    }
12821
12822    /// Remove one owner's interests, preserving unrelated owner/base interests.
12823    ///
12824    /// The owner's queued backfills are dropped, and source-id/anchor
12825    /// bookkeeping for filters no other owner references is retired. Live
12826    /// streams for retired filters are torn down on the next
12827    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
12828    /// unsubscribes provider-side); events already in flight from them stop
12829    /// matching the merged interest set and are discarded.
12830    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
12831        let index = self
12832            .owned_interests
12833            .iter()
12834            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
12835        let removed = self.owned_interests.remove(index);
12836        if let Some(epoch) = &removed.epoch {
12837            self.purge_owner_epoch(epoch);
12838        } else {
12839            self.pending_backfills
12840                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
12841            self.recent_compat_owner_input_refs.remove(owner);
12842            self.recent_compat_owner_input_ref_sets.remove(owner);
12843        }
12844        self.rebuild_registered_interests();
12845        self.retire_unreferenced_filters();
12846        self.sources_dirty = true;
12847        Some(removed.interests)
12848    }
12849
12850    /// Borrow the interests currently owned by `owner`.
12851    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
12852        self.owned_interests
12853            .iter()
12854            .find(|entry| &entry.owner == owner)
12855            .map(|entry| entry.interests.as_slice())
12856    }
12857
12858    fn set_interest_owner(
12859        &mut self,
12860        owner: HandlerId,
12861        interests: &[ReactiveInterest<N>],
12862        backfill: Option<SubscriberBackfill>,
12863    ) -> Result<(), SubscriberError> {
12864        validate_subscriber_config(&self.config)?;
12865        if self
12866            .owned_interests
12867            .iter()
12868            .any(|entry| entry.owner == owner && entry.epoch.is_some())
12869        {
12870            return Err(SubscriberError::InvalidConfig(
12871                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
12872            ));
12873        }
12874
12875        let mut next_owned = self.clone_owned_interests();
12876        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
12877            Some(entry) => {
12878                entry.interests = interests.to_vec();
12879                entry.state = SubscriberOwnerState::Active;
12880                entry.baseline = None;
12881                entry.progress = None;
12882                entry.progress_stream_revision = None;
12883                entry.epoch.take()
12884            }
12885            None => {
12886                next_owned.push(OwnedSubscriberInterests {
12887                    owner: owner.clone(),
12888                    interests: interests.to_vec(),
12889                    epoch: None,
12890                    state: SubscriberOwnerState::Active,
12891                    baseline: None,
12892                    progress: None,
12893                    progress_stream_revision: None,
12894                });
12895                None
12896            }
12897        };
12898        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12899        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12900
12901        // Continuity capture, before the mutation lands: the owner's previous
12902        // filter shapes and the oldest delivery anchor among them. A changed
12903        // filter gets a fresh source id with no anchor, so without this
12904        // hand-off, replacing an owner's interests (the normal way to grow a
12905        // pool set) would silently discard the delivery watermark and open a
12906        // gap until some later explicit backfill.
12907        let previous_filters: Vec<Filter> = self
12908            .owner_interests(&owner)
12909            .map(log_filters)
12910            .unwrap_or_default();
12911        let continuity_anchor: Option<u64> = previous_filters
12912            .iter()
12913            .filter_map(|filter| self.log_anchor(filter))
12914            .min();
12915
12916        // Build the replacement queue before committing owner state. Capacity
12917        // failure is therefore atomic and cannot leave desired interests ahead
12918        // of the historical work required to make them continuous.
12919        let mut replacement_backfills = Vec::new();
12920        let filters = log_filters(interests);
12921        if let Some(backfill) = backfill
12922            && !filters.is_empty()
12923        {
12924            replacement_backfills.push(QueuedSubscriberBackfill {
12925                owner: Some(owner.clone()),
12926                epoch: None,
12927                filters: filters.clone(),
12928                backfill,
12929            });
12930        }
12931        let explicit_covers = backfill.is_some_and(|explicit| {
12932            explicit.end_block().is_none()
12933                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
12934        });
12935        let continuity_filters: Vec<_> = filters
12936            .into_iter()
12937            .filter(|filter| !previous_filters.contains(filter))
12938            .collect();
12939        if let Some(anchor) = continuity_anchor
12940            && !continuity_filters.is_empty()
12941            && !explicit_covers
12942        {
12943            replacement_backfills.push(QueuedSubscriberBackfill {
12944                owner: Some(owner.clone()),
12945                epoch: None,
12946                filters: continuity_filters,
12947                backfill: SubscriberBackfill::from_block(anchor),
12948            });
12949        }
12950        let retained_backfills = self
12951            .pending_backfills
12952            .iter()
12953            .filter(|queued| queued.owner.as_ref() != Some(&owner))
12954            .map(|queued| queued.filters.len())
12955            .sum::<usize>();
12956        let replacement_units = replacement_backfills
12957            .iter()
12958            .map(|queued| queued.filters.len())
12959            .sum::<usize>();
12960        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
12961        {
12962            return Err(SubscriberError::ResourceExhausted(format!(
12963                "owner update would queue more than {} lazy backfills",
12964                self.config.max_pending_backfills
12965            )));
12966        }
12967
12968        self.owned_interests = next_owned;
12969        self.interests = next_registered;
12970        if let Some(epoch) = replaced_epoch {
12971            self.purge_owner_epoch(&epoch);
12972        } else {
12973            self.recent_compat_owner_input_refs.remove(&owner);
12974            self.recent_compat_owner_input_ref_sets.remove(&owner);
12975        }
12976        self.retire_unreferenced_filters();
12977        self.sources_dirty = true;
12978
12979        // Re-queue this owner's backfills from scratch: previously queued
12980        // entries may reference filter shapes that no longer exist.
12981        self.pending_backfills
12982            .retain(|queued| queued.owner.as_ref() != Some(&owner));
12983        self.pending_backfills.extend(replacement_backfills);
12984        Ok(())
12985    }
12986
12987    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
12988        self.owned_interests
12989            .iter()
12990            .map(|entry| OwnedSubscriberInterests {
12991                owner: entry.owner.clone(),
12992                interests: entry.interests.clone(),
12993                epoch: entry.epoch.clone(),
12994                state: entry.state,
12995                baseline: entry.baseline,
12996                progress: entry.progress.clone(),
12997                progress_stream_revision: entry.progress_stream_revision,
12998            })
12999            .collect()
13000    }
13001
13002    fn rebuild_registered_interests(&mut self) {
13003        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
13004    }
13005
13006    /// Delivery anchor (last block known fully delivered) for `filter`, if the
13007    /// filter has a source id and has seen delivery.
13008    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
13009        if let Some(anchor) = self
13010            .log_source_ids
13011            .get(filter)
13012            .and_then(|id| self.last_seen_log_blocks.get(id))
13013        {
13014            return Some(*anchor);
13015        }
13016
13017        // Logical owner filters may be represented by a broader provider
13018        // stream after fan-in. Its oldest live watermark is a conservative
13019        // continuity anchor: it can cause extra backfill, never a missed log.
13020        self.log_source_ids
13021            .values()
13022            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
13023            .min()
13024    }
13025
13026    /// Every logical log filter across base and owner interests, merged within
13027    /// each origin and deduplicated across origins. These shapes remain the
13028    /// exact routing and owner-continuity boundary; provider subscriptions may
13029    /// fan several of them into one broader filter.
13030    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13031    // `mutable_key_type` lint is a known false positive for it.
13032    #[allow(clippy::mutable_key_type)]
13033    fn logical_log_filters(&self) -> Vec<Filter> {
13034        let mut filters = log_filters(&self.base_interests);
13035        for entry in &self.owned_interests {
13036            filters.extend(log_filters(&entry.interests));
13037        }
13038        let mut seen = HashSet::new();
13039        filters.retain(|filter| seen.insert(filter.clone()));
13040        filters
13041    }
13042
13043    /// Provider-facing log filters. Compatible logical filters fan into a
13044    /// small number of address/topic supersets, then split only when the
13045    /// configured address ceiling requires it. Exact matching remains local in
13046    /// `enqueue_event`, so this reduces subscriptions without broadening owner
13047    /// delivery.
13048    fn log_stream_filters(&self) -> Vec<Filter> {
13049        let mut merged = Vec::new();
13050        for filter in self.logical_log_filters() {
13051            merge_log_subscription_filter(&mut merged, &filter);
13052        }
13053
13054        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
13055        let mut planned = Vec::new();
13056        for filter in merged {
13057            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
13058            if addresses.len() <= max_addresses {
13059                planned.push(filter);
13060                continue;
13061            }
13062            addresses.sort_unstable();
13063            for chunk in addresses.chunks(max_addresses) {
13064                let mut split = filter.clone();
13065                split.address = FilterSet::default();
13066                for address in chunk {
13067                    split.address.insert(*address);
13068                }
13069                planned.push(split);
13070            }
13071        }
13072        planned
13073    }
13074
13075    /// Drop source-id and anchor bookkeeping for filters no longer referenced
13076    /// by any base or owner interest, so long-lived owner churn cannot grow the
13077    /// maps unboundedly. Live streams for retired filters are pruned by the
13078    /// next reconcile.
13079    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13080    // `mutable_key_type` lint is a known false positive for it.
13081    #[allow(clippy::mutable_key_type)]
13082    fn retire_unreferenced_filters(&mut self) {
13083        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
13084        if let AlloySubscriberState::Active(streams) = &self.state {
13085            for entry in &streams.entries {
13086                match &entry.source {
13087                    SubscriberStreamSource::PubSubLog { filter, .. }
13088                    | SubscriberStreamSource::BasePendingLog { filter, .. }
13089                    | SubscriberStreamSource::PollingLog { filter } => {
13090                        live.insert(filter.clone());
13091                    }
13092                    SubscriberStreamSource::BaseFlashblocks
13093                    | SubscriberStreamSource::OpPendingFlashblocks
13094                    | SubscriberStreamSource::PubSubPendingHashes
13095                    | SubscriberStreamSource::PubSubBlockHeaders
13096                    | SubscriberStreamSource::PollingPendingHashes => {}
13097                }
13098            }
13099        }
13100        self.log_source_ids
13101            .retain(|filter, _| live.contains(filter));
13102        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
13103        self.last_seen_log_blocks
13104            .retain(|id, _| live_ids.contains(id));
13105    }
13106
13107    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
13108        if self.pending_records.is_empty() && self.pending_chain_controls.is_empty() {
13109            return None;
13110        }
13111
13112        let first_preconfirmation = self.pending_records.front().and_then(|record| {
13113            if record.scope != SubscriberInputScope::Preconfirmed {
13114                return None;
13115            }
13116            match &record.record.context.chain_status {
13117                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
13118                _ => None,
13119            }
13120        });
13121        let len = self
13122            .pending_records
13123            .iter()
13124            .take(self.config.max_batch_size)
13125            .take_while(|record| match &first_preconfirmation {
13126                Some(expected) => {
13127                    record.scope == SubscriberInputScope::Preconfirmed
13128                        && matches!(
13129                            &record.record.context.chain_status,
13130                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
13131                        )
13132                }
13133                None => record.scope != SubscriberInputScope::Preconfirmed,
13134            })
13135            .count();
13136        let records = self.pending_records.drain(..len).collect();
13137        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
13138            self.pending_chain_controls.drain(..).collect()
13139        } else {
13140            Vec::new()
13141        };
13142        Some(SubscriberInputBatch {
13143            records,
13144            chain_id: self.chain_id,
13145            chain_controls,
13146        })
13147    }
13148
13149    fn reset_delivery_state(&mut self) {
13150        self.pending_records.clear();
13151        self.pending_chain_controls.clear();
13152        self.pending_reconcile_owner_records.clear();
13153        self.resource_error = None;
13154        self.last_seen_log_blocks.clear();
13155        self.verified_log_blocks.clear();
13156        self.verified_log_block_order.clear();
13157        self.recent_input_refs.clear();
13158        self.recent_input_ref_set.clear();
13159        self.recent_owner_input_refs.clear();
13160        self.recent_owner_input_ref_sets.clear();
13161        self.recent_compat_owner_input_refs.clear();
13162        self.recent_compat_owner_input_ref_sets.clear();
13163        self.pending_backfills.clear();
13164        self.pending_source_backfills.clear();
13165        self.log_source_ids.clear();
13166        self.next_log_source_id = 0;
13167        self.sources_dirty = true;
13168        self.reset_flashblock_tracking();
13169    }
13170
13171    fn reset_flashblock_tracking(&mut self) {
13172        self.base_flashblock_header = None;
13173        self.flashblocks_by_hash.clear();
13174        self.flashblock_hash_order.clear();
13175        self.unmatched_pending_logs.clear();
13176        self.latest_preconfirmation = None;
13177        self.preconfirmed_seen_logs.clear();
13178    }
13179
13180    fn bump_stream_revision(&mut self) {
13181        self.stream_revision = self.stream_revision.saturating_add(1);
13182    }
13183}
13184
13185impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
13186where
13187    P: Provider<N> + Send + Sync,
13188    N: Network + 'static,
13189    N::HeaderResponse: Send + 'static,
13190{
13191    fn upsert_interest_owners(
13192        &mut self,
13193        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13194    ) -> SubscriberOperation<'_, ()> {
13195        Box::pin(async move {
13196            if !owners.is_empty() {
13197                self.ensure_chain_id().await?;
13198            }
13199            AlloySubscriber::upsert_interest_owners(self, owners)
13200        })
13201    }
13202
13203    fn replace_interest_owners(
13204        &mut self,
13205        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13206    ) -> SubscriberOperation<'_, ()> {
13207        Box::pin(async move {
13208            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
13209                self.ensure_chain_id().await?;
13210            }
13211            AlloySubscriber::replace_interest_owners(self, owners)
13212        })
13213    }
13214
13215    fn replace_interest_owners_with_global_backfill(
13216        &mut self,
13217        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13218        backfill: SubscriberBackfill,
13219    ) -> SubscriberOperation<'_, ()> {
13220        Box::pin(async move {
13221            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
13222                self.ensure_chain_id().await?;
13223            }
13224            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
13225        })
13226    }
13227
13228    fn add_interest_owner(
13229        &mut self,
13230        owner: HandlerId,
13231        interests: &[ReactiveInterest<N>],
13232    ) -> SubscriberOperation<'_, ()> {
13233        let interests = interests.to_vec();
13234        Box::pin(async move {
13235            if !interests.is_empty() {
13236                self.ensure_chain_id().await?;
13237            }
13238            AlloySubscriber::add_interest_owner(self, owner, &interests)
13239        })
13240    }
13241
13242    fn add_interest_owner_with_backfill(
13243        &mut self,
13244        owner: HandlerId,
13245        interests: &[ReactiveInterest<N>],
13246        backfill: SubscriberBackfill,
13247    ) -> SubscriberOperation<'_, ()> {
13248        let interests = interests.to_vec();
13249        Box::pin(async move {
13250            if !interests.is_empty() {
13251                self.ensure_chain_id().await?;
13252            }
13253            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
13254        })
13255    }
13256
13257    fn add_interest_owner_with_canonical_catchup(
13258        &mut self,
13259        owner: HandlerId,
13260        interests: &[ReactiveInterest<N>],
13261        retained: BlockRef,
13262    ) -> SubscriberOperation<'_, ()> {
13263        let interests = interests.to_vec();
13264        Box::pin(async move {
13265            // Resolve provider identity before the synchronous topology commit;
13266            // cancellation or failure at this await leaves prior state intact.
13267            self.ensure_chain_id().await?;
13268            AlloySubscriber::add_interest_owner_with_canonical_catchup(
13269                self, owner, &interests, retained,
13270            )
13271        })
13272    }
13273
13274    fn remove_interest_owner(
13275        &mut self,
13276        owner: &HandlerId,
13277    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
13278        let owner = owner.clone();
13279        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
13280    }
13281
13282    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
13283        AlloySubscriber::owner_interests(self, owner)
13284    }
13285}
13286
13287enum AlloySubscriberState<N: Network> {
13288    Uninitialized,
13289    Active(SubscriberStreams<N>),
13290    Empty,
13291}
13292
13293struct SubscriberStreams<N: Network> {
13294    entries: Vec<SubscriberStreamEntry<N>>,
13295    next_index: usize,
13296}
13297
13298struct SubscriberStreamEntry<N: Network> {
13299    source: SubscriberStreamSource,
13300    stream: BoxStream<'static, SubscriberEvent<N>>,
13301}
13302
13303impl<N: Network> SubscriberStreams<N> {
13304    fn new() -> Self {
13305        Self {
13306            entries: Vec::new(),
13307            next_index: 0,
13308        }
13309    }
13310
13311    fn is_empty(&self) -> bool {
13312        self.entries.is_empty()
13313    }
13314
13315    fn push(
13316        &mut self,
13317        source: SubscriberStreamSource,
13318        stream: BoxStream<'static, SubscriberEvent<N>>,
13319    ) {
13320        self.entries.push(SubscriberStreamEntry { source, stream });
13321    }
13322
13323    #[cfg(test)]
13324    fn len(&self) -> usize {
13325        self.entries.len()
13326    }
13327
13328    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
13329        self.entries
13330            .iter()
13331            .any(|entry| entry.source.same_key(source))
13332    }
13333
13334    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
13335        self.entries
13336            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
13337        self.normalize_next_index();
13338    }
13339
13340    fn normalize_next_index(&mut self) {
13341        if self.entries.is_empty() {
13342            self.next_index = 0;
13343        } else if self.next_index >= self.entries.len() {
13344            self.next_index %= self.entries.len();
13345        }
13346    }
13347
13348    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
13349        poll_fn(|cx| {
13350            self.normalize_next_index();
13351            if self.entries.is_empty() {
13352                return std::task::Poll::Ready(None);
13353            }
13354
13355            let mut index = self.next_index;
13356            let mut checked = 0usize;
13357            while checked < self.entries.len() {
13358                if index >= self.entries.len() {
13359                    index = 0;
13360                }
13361                match self.entries[index].stream.as_mut().poll_next(cx) {
13362                    std::task::Poll::Ready(Some(event)) => {
13363                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
13364                            self.entries.remove(index);
13365                            self.next_index = if self.entries.is_empty() {
13366                                0
13367                            } else {
13368                                index % self.entries.len()
13369                            };
13370                        } else {
13371                            self.next_index = (index + 1) % self.entries.len();
13372                        }
13373                        return std::task::Poll::Ready(Some(event));
13374                    }
13375                    std::task::Poll::Ready(None) => {
13376                        self.entries.remove(index);
13377                        if self.entries.is_empty() {
13378                            self.next_index = 0;
13379                            return std::task::Poll::Ready(None);
13380                        }
13381                    }
13382                    std::task::Poll::Pending => {
13383                        checked += 1;
13384                        index += 1;
13385                    }
13386                }
13387            }
13388
13389            if self.entries.is_empty() {
13390                std::task::Poll::Ready(None)
13391            } else {
13392                self.next_index = index % self.entries.len();
13393                std::task::Poll::Pending
13394            }
13395        })
13396        .await
13397    }
13398}
13399
13400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13401#[allow(dead_code)]
13402enum SubscriberTransport {
13403    PubSub,
13404    Polling,
13405}
13406
13407#[derive(Clone, Debug)]
13408enum SubscriberStreamSource {
13409    PubSubLog { id: usize, filter: Filter },
13410    BasePendingLog { id: usize, filter: Filter },
13411    BaseFlashblocks,
13412    OpPendingFlashblocks,
13413    PubSubPendingHashes,
13414    PubSubBlockHeaders,
13415    PollingLog { filter: Filter },
13416    PollingPendingHashes,
13417}
13418
13419impl SubscriberStreamSource {
13420    fn label(&self) -> &'static str {
13421        match self {
13422            Self::PubSubLog { .. } => "pubsub log",
13423            Self::BasePendingLog { .. } => "Base pendingLogs",
13424            Self::BaseFlashblocks => "Base newFlashblocks",
13425            Self::OpPendingFlashblocks => "OP pending Flashblocks",
13426            Self::PubSubPendingHashes => "pubsub pending transaction hash",
13427            Self::PubSubBlockHeaders => "pubsub block header",
13428            Self::PollingLog { .. } => "polling log",
13429            Self::PollingPendingHashes => "polling pending transaction hash",
13430        }
13431    }
13432
13433    fn is_pubsub(&self) -> bool {
13434        matches!(
13435            self,
13436            Self::PubSubLog { .. }
13437                | Self::BasePendingLog { .. }
13438                | Self::BaseFlashblocks
13439                | Self::PubSubPendingHashes
13440                | Self::PubSubBlockHeaders
13441        )
13442    }
13443
13444    fn is_flashblocks(&self) -> bool {
13445        matches!(
13446            self,
13447            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
13448        )
13449    }
13450
13451    fn same_key(&self, other: &Self) -> bool {
13452        match (self, other) {
13453            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
13454            | (
13455                Self::BasePendingLog { filter: left, .. },
13456                Self::BasePendingLog { filter: right, .. },
13457            )
13458            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
13459                left == right
13460            }
13461            (Self::BaseFlashblocks, Self::BaseFlashblocks)
13462            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
13463            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
13464            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
13465            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
13466            _ => false,
13467        }
13468    }
13469}
13470
13471#[allow(dead_code)]
13472enum SubscriberEvent<N: Network> {
13473    Log {
13474        source_id: usize,
13475        log: Log,
13476    },
13477    BackfilledLogs {
13478        source_id: usize,
13479        logs: Vec<Log>,
13480    },
13481    Logs(Vec<Log>),
13482    BlockHeader(N::HeaderResponse),
13483    PendingHash(B256),
13484    PendingHashes(Vec<B256>),
13485    BasePendingLog {
13486        source_id: usize,
13487        log: Log,
13488    },
13489    BaseFlashblock(BaseFlashblockWirePayload),
13490    OpFlashblockTick,
13491    PreconfirmedLogs {
13492        flashblock: FlashblockRef,
13493        logs: Vec<Log>,
13494    },
13495    FlashblockObserved,
13496    StreamTerminated(SubscriberStreamSource),
13497}
13498
13499impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
13500where
13501    P: Provider<N> + Send + Sync,
13502    N: Network + 'static,
13503    N::HeaderResponse: Send + 'static,
13504{
13505    fn chain_id(&self) -> Option<u64> {
13506        self.chain_id
13507    }
13508
13509    fn capabilities(&self) -> SubscriberCapabilities {
13510        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
13511            return SubscriberCapabilities::default();
13512        };
13513        let mut capabilities = vec![
13514            SubscriberCapability::Logs,
13515            SubscriberCapability::PendingTransactionHashes,
13516            SubscriberCapability::HistoricalBackfill,
13517            SubscriberCapability::Live,
13518            SubscriberCapability::OwnerScopedDelivery,
13519            SubscriberCapability::DynamicInterests,
13520        ];
13521        if transport == SubscriberTransport::PubSub {
13522            capabilities.push(SubscriberCapability::BlockHeaders);
13523        }
13524        if self.config.preconfirmations != PreconfirmationMode::Disabled
13525            && self.provider_ref.is_some()
13526            && self.chain_id.and_then(flashblocks_adapter).is_some()
13527        {
13528            capabilities.push(SubscriberCapability::Preconfirmations);
13529        }
13530        SubscriberCapabilities::new(capabilities)
13531    }
13532
13533    fn register_interests(
13534        &mut self,
13535        interests: &[ReactiveInterest<N>],
13536    ) -> SubscriberOperation<'_, ()> {
13537        let interests = interests.to_vec();
13538        Box::pin(async move {
13539            validate_subscriber_config(&self.config)?;
13540            validate_supported_interests(self.mode, &self.config, &interests)?;
13541            if !interests.is_empty() {
13542                self.ensure_chain_id().await?;
13543            }
13544            self.validate_flashblocks_setup()?;
13545
13546            self.base_interests = interests;
13547            self.owned_interests.clear();
13548            self.rebuild_registered_interests();
13549            self.reset_delivery_state();
13550            self.state = AlloySubscriberState::Uninitialized;
13551            Ok(())
13552        })
13553    }
13554
13555    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
13556        Box::pin(async {
13557            Ok(self
13558                .next_scoped_batch()
13559                .await?
13560                .map(SubscriberInputBatch::into_reactive_batch))
13561        })
13562    }
13563}
13564
13565impl<P, N> AlloySubscriber<P, N>
13566where
13567    P: Provider<N> + Send + Sync,
13568    N: Network + 'static,
13569    N::HeaderResponse: Send + 'static,
13570{
13571    /// Resolve the provider's chain identity once. The assignment happens only
13572    /// after a complete RPC response, so cancelling the future leaves the
13573    /// subscriber cleanly retryable.
13574    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
13575        if let Some(chain_id) = self.chain_id {
13576            return Ok(chain_id);
13577        }
13578        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
13579        self.chain_id = Some(chain_id);
13580        Ok(chain_id)
13581    }
13582
13583    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
13584        if self.config.preconfirmations == PreconfirmationMode::Disabled {
13585            return Ok(());
13586        }
13587        if self.provider_ref.is_none() {
13588            return Err(SubscriberError::InvalidConfig(
13589                "Flashblocks require a stable provider ref from a pinned provider lease",
13590            ));
13591        }
13592        let Some(chain_id) = self.chain_id else {
13593            return Ok(());
13594        };
13595        match flashblocks_adapter(chain_id) {
13596            Some(FlashblocksAdapter::BaseNative)
13597                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
13598                    && self.config.preconfirmations == PreconfirmationMode::Required =>
13599            {
13600                return Err(SubscriberError::Unsupported(
13601                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
13602                ));
13603            }
13604            Some(_) => {}
13605            None if self.config.preconfirmations == PreconfirmationMode::Required => {
13606                return Err(SubscriberError::Unsupported(
13607                    "Flashblocks are currently implemented for Base and OP chains",
13608                ));
13609            }
13610            None => {}
13611        }
13612        Ok(())
13613    }
13614
13615    /// Subscribe first, then catch an exact staged owner up through a verified
13616    /// canonical block.
13617    ///
13618    /// This compatibility wrapper delegates to
13619    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
13620    /// driver adopting several owners should call the bulk API once rather than
13621    /// invoking this method in a loop.
13622    ///
13623    /// # Errors
13624    ///
13625    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
13626    /// baseline, conflicts/regresses, provider certification or transport
13627    /// fails, returned logs are invalid, or subscriber resources are exhausted.
13628    pub async fn reconcile_interest_owner(
13629        &mut self,
13630        epoch: &SubscriberOwnerEpoch,
13631        through: BlockRef,
13632    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
13633    where
13634        P: Clone,
13635    {
13636        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
13637            .await?
13638            .pop()
13639            .ok_or(SubscriberOwnerError::NotStaged)
13640    }
13641
13642    /// Subscribe first, then atomically catch staged owners up through one
13643    /// verified canonical block.
13644    ///
13645    /// All epochs are preflighted before provider I/O. Live streams are
13646    /// reconciled once, compatible provider filters are merged into bounded
13647    /// chunks, and every historical request shares one double target-header
13648    /// certification. Provider-filter supersets are routed back through each
13649    /// owner's exact interests, retaining owner-scoped delivery provenance.
13650    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
13651    ///
13652    /// Live events are continuously drained while an independent provider
13653    /// clone performs catch-up. Fetched owner records and progress become
13654    /// visible only after every request and the final certification succeed. A
13655    /// failure leaves every target staged with its prior progress unchanged;
13656    /// live canonical delivery consumed during the attempt is preserved while
13657    /// excluding the failed target epochs from its staged-owner audience.
13658    ///
13659    /// # Errors
13660    ///
13661    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
13662    /// baseline, conflicts/regresses, provider certification or transport
13663    /// fails, returned logs are invalid, or subscriber resources are exhausted.
13664    /// Target progress remains unchanged on error.
13665    pub async fn reconcile_interest_owners(
13666        &mut self,
13667        epochs: &[SubscriberOwnerEpoch],
13668        through: BlockRef,
13669    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
13670    where
13671        P: Clone,
13672    {
13673        if epochs.is_empty() {
13674            return Ok(Vec::new());
13675        }
13676
13677        self.ensure_chain_id().await?;
13678
13679        let mut seen = HashSet::new();
13680        let mut plans = Vec::with_capacity(epochs.len());
13681        for epoch in epochs {
13682            if !seen.insert(epoch.clone()) {
13683                continue;
13684            }
13685            let entry = self
13686                .owned_interests
13687                .iter()
13688                .find(|entry| {
13689                    entry.epoch.as_ref() == Some(epoch)
13690                        && entry.state == SubscriberOwnerState::Staged
13691                })
13692                .ok_or(SubscriberOwnerError::NotStaged)?;
13693            let position = entry
13694                .progress
13695                .as_ref()
13696                .map(|progress| &progress.through)
13697                .or(entry.baseline.as_ref())
13698                .ok_or(SubscriberOwnerError::MissingBaseline)?;
13699            let baseline = position.number;
13700            if through.number < baseline {
13701                return Err(SubscriberOwnerError::ProgressRegression {
13702                    current: baseline,
13703                    target: through.number,
13704                });
13705            }
13706            let from_block = baseline
13707                .checked_add(1)
13708                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
13709            if through.number == baseline && through.hash != position.hash {
13710                return Err(SubscriberOwnerError::ProgressConflict {
13711                    number: baseline,
13712                    current_hash: position.hash,
13713                    target_hash: through.hash,
13714                });
13715            }
13716            if through.number == from_block
13717                && through
13718                    .parent_hash
13719                    .is_some_and(|parent| parent != position.hash)
13720            {
13721                return Err(SubscriberOwnerError::ProgressConflict {
13722                    number: baseline,
13723                    current_hash: position.hash,
13724                    target_hash: through.parent_hash.expect("checked as present above"),
13725                });
13726            }
13727            if entry
13728                .interests
13729                .iter()
13730                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13731            {
13732                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
13733            }
13734            plans.push(SubscriberOwnerReconcilePlan {
13735                epoch: epoch.clone(),
13736                interests: entry.interests.clone(),
13737                retained: *position,
13738                from_block,
13739            });
13740        }
13741
13742        // The ordering is intentional and part of the public continuity
13743        // contract: connect first, then fetch the bounded historical window.
13744        self.ensure_streams().await?;
13745        let provider = self.provider.clone();
13746        let filters = merged_owner_reconcile_filters(&plans, through.number);
13747        let retained = plans.iter().map(|plan| plan.retained).collect();
13748        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
13749        let fetch = fetch_owner_catchup::<P, N>(
13750            provider,
13751            filters,
13752            retained,
13753            through,
13754            SubscriberOwnerCatchupOptions {
13755                target_preverified: false,
13756                max_logs: self.config.max_pending_records,
13757                max_log_bytes: self.config.max_backfill_log_bytes,
13758                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
13759            },
13760        );
13761        let SubscriberOwnerCatchup { logs, certified } =
13762            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
13763
13764        let records = logs
13765            .into_iter()
13766            .map(|log| log_input_record(log, InputSource::Backfill))
13767            .collect();
13768        let mut routed_records = Vec::new();
13769        for record in dedupe_records(sort_records(records)).map_err(|error| {
13770            SubscriberError::InvalidBackfill(format!(
13771                "conflicting duplicate owner catch-up record: {error}"
13772            ))
13773        })? {
13774            let block_number = match &record.input {
13775                ReactiveInput::Log(log) => log
13776                    .block_number
13777                    .expect("bulk catch-up logs were validated before commit"),
13778                _ => unreachable!("bulk owner catch-up contains log records only"),
13779            };
13780            let owners: Vec<SubscriberOwnerEpoch> = plans
13781                .iter()
13782                .filter(|plan| block_number >= plan.from_block)
13783                .filter(|plan| {
13784                    plan.interests
13785                        .iter()
13786                        .any(|interest| interest_matches(interest, &record.input))
13787                })
13788                .map(|plan| plan.epoch.clone())
13789                .collect();
13790            if !owners.is_empty() {
13791                routed_records.push((record, owners));
13792            }
13793        }
13794        self.ensure_pending_record_capacity(
13795            routed_records.len(),
13796            "owner reconciliation historical records",
13797        )?;
13798
13799        // Nothing provider-derived becomes authoritative until every record is
13800        // known to fit. In particular, preserve queued retry state and owner
13801        // progress when the bounded delivery queue cannot accept the catch-up.
13802        self.pending_backfills.retain(|queued| {
13803            queued
13804                .epoch
13805                .as_ref()
13806                .is_none_or(|epoch| !target_epochs.contains(epoch))
13807        });
13808        for (record, owners) in routed_records {
13809            self.enqueue_owner_record_for_owners_unmerged(record, owners);
13810        }
13811        self.promote_reconcile_owner_records(&target_epochs);
13812        self.seed_reconciled_filter_anchors(&plans, certified.number);
13813
13814        let stream_revision = self.stream_revision;
13815        let mut progress = Vec::with_capacity(plans.len());
13816        for plan in plans {
13817            let item = SubscriberOwnerProgress {
13818                owner: plan.epoch.clone(),
13819                through: certified,
13820            };
13821            let entry = self
13822                .owned_interests
13823                .iter_mut()
13824                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
13825                .expect("bulk reconcile holds exclusive access after epoch preflight");
13826            entry.progress = Some(item.clone());
13827            entry.progress_stream_revision = Some(stream_revision);
13828            progress.push(item);
13829        }
13830        Ok(progress)
13831    }
13832
13833    async fn drive_reconcile_fetch<T, F>(
13834        &mut self,
13835        fetch: F,
13836        target_epochs: &HashSet<SubscriberOwnerEpoch>,
13837    ) -> Result<T, SubscriberOwnerError>
13838    where
13839        F: Future<Output = Result<T, SubscriberOwnerError>>,
13840    {
13841        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
13842            return fetch.await;
13843        }
13844        let mut fetch = Box::pin(fetch);
13845        loop {
13846            let event = {
13847                let live = Box::pin(self.next_event());
13848                match select(fetch, live).await {
13849                    Either::Left((result, pending_live)) => {
13850                        drop(pending_live);
13851                        return result;
13852                    }
13853                    Either::Right((event, pending_fetch)) => {
13854                        fetch = pending_fetch;
13855                        event
13856                    }
13857                }
13858            };
13859            let event = event?.ok_or_else(|| {
13860                SubscriberError::Provider(
13861                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
13862                )
13863            })?;
13864            self.buffer_reconcile_event_for_owners(&event, target_epochs);
13865            self.enqueue_event_excluding_owners(event, target_epochs);
13866            self.check_resource_error()?;
13867        }
13868    }
13869
13870    /// Poll one driver control future with priority over the next scoped batch.
13871    ///
13872    /// This is the supported control-interleaving primitive for a subscriber
13873    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
13874    /// the caller's pending control future alive. When control wins, the
13875    /// in-progress subscriber poll is cancelled at a documented safe boundary:
13876    /// queued records are removed only when a complete batch is returned,
13877    /// successful backfill steps are committed before the next await, provider
13878    /// streams created but not installed are dropped, and installed streams
13879    /// remain owned by the subscriber for the next call.
13880    ///
13881    /// The control future is polled first. Therefore a ready shutdown/removal
13882    /// command cannot starve behind a continuously ready subscriber queue.
13883    ///
13884    /// # Errors
13885    ///
13886    /// Returns [`SubscriberError`] when the subscriber poll encounters a
13887    /// transport, continuity, decoding, configuration, or resource failure.
13888    pub async fn next_scoped_batch_or<C, F>(
13889        &mut self,
13890        control: Pin<&mut F>,
13891    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
13892    where
13893        C: Send,
13894        F: Future<Output = C> + Send,
13895    {
13896        let batch = self.next_scoped_batch();
13897        match select(control, batch).await {
13898            Either::Left((control, pending_batch)) => {
13899                drop(pending_batch);
13900                Ok(SubscriberDriverPoll::Control(control))
13901            }
13902            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
13903        }
13904    }
13905
13906    /// Return the next subscriber batch while retaining staged-owner delivery
13907    /// provenance captured at enqueue time.
13908    ///
13909    /// Transaction-aware drivers must use this method. The compatibility
13910    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
13911    /// its historical behavior for existing callers.
13912    ///
13913    /// For command interleaving, prefer
13914    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
13915    /// cancellation-safety invariants of this poll and prioritizes ready control.
13916    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
13917        Box::pin(async {
13918            self.check_resource_error()?;
13919            if self.chain_id.is_none()
13920                && (!self.pending_records.is_empty()
13921                    || !self.pending_chain_controls.is_empty()
13922                    || !self.pending_backfills.is_empty()
13923                    || !self.interests.is_empty())
13924            {
13925                self.ensure_chain_id().await?;
13926            }
13927            if let Some(batch) = self.drain_next_scoped_batch() {
13928                return Ok(Some(batch));
13929            }
13930
13931            // Subscribe/adopt the complete desired topology before resolving
13932            // any queued historical upper bound. Live streams therefore own
13933            // every event that can arrive while the bounded backfill is in
13934            // flight, including the coordinated registration window.
13935            self.ensure_streams().await?;
13936            self.check_resource_error()?;
13937            if let Some(batch) = self.drain_next_scoped_batch() {
13938                return Ok(Some(batch));
13939            }
13940
13941            self.drain_pending_backfills().await?;
13942            self.check_resource_error()?;
13943            if let Some(batch) = self.drain_next_scoped_batch() {
13944                return Ok(Some(batch));
13945            }
13946
13947            if self.interests.is_empty() {
13948                return Ok(None);
13949            }
13950
13951            loop {
13952                let Some(event) = self.next_event().await? else {
13953                    return Ok(None);
13954                };
13955
13956                self.enqueue_event(event);
13957                self.check_resource_error()?;
13958                if let Some(batch) = self.drain_next_scoped_batch() {
13959                    return Ok(Some(batch));
13960                }
13961            }
13962        })
13963    }
13964
13965    /// Bring live streams in line with the current interest set.
13966    ///
13967    /// Runs incrementally: the desired-vs-live diff only happens when interest
13968    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
13969    /// steady-state polling costs nothing here. Missing sources are connected,
13970    /// sources for retired filters are dropped (dropping an Alloy subscription
13971    /// unsubscribes provider-side), and unrelated live streams — with their
13972    /// delivery and anchor state — are left untouched.
13973    ///
13974    /// A newly connected log source whose filter already has a delivery anchor
13975    /// is caught up from that anchor immediately after subscribing (the same
13976    /// subscribe-then-backfill order the reconnect path uses). Together with
13977    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
13978    /// window between an adoption backfill and live stream start.
13979    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
13980        if !self.sources_dirty {
13981            return Ok(());
13982        }
13983        // An interest-less subscriber never touches the provider
13984        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
13985        // the empty desired topology as clean so a deliberately empty staged
13986        // epoch can reconcile and activate instead of remaining dirty forever.
13987        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
13988            self.bump_stream_revision();
13989            self.sources_dirty = false;
13990            return Ok(());
13991        }
13992
13993        let desired = self.stream_sources()?;
13994        let missing: Vec<SubscriberStreamSource> = match &self.state {
13995            AlloySubscriberState::Active(streams) => desired
13996                .iter()
13997                .filter(|source| !streams.contains_source(source))
13998                .cloned()
13999                .collect(),
14000            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
14001        };
14002
14003        for source in missing {
14004            let stream = self.connect_source_stream(source.clone()).await?;
14005            // Publish each successful connection before any later await. If a
14006            // second connection or anchored catch-up fails/cancels, this stream
14007            // remains live and the next reconcile skips reconnecting it.
14008            self.install_source_stream(source.clone(), stream);
14009            if self.source_requires_backfill(&source) {
14010                self.queue_source_backfill(source);
14011            }
14012        }
14013
14014        while let Some(source) = self.pending_source_backfills.front().cloned() {
14015            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
14016                && matches!(
14017                    &self.state,
14018                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
14019                );
14020            if !desired_and_live {
14021                self.pending_source_backfills.pop_front();
14022                continue;
14023            }
14024
14025            // Anchored catch-up for a source with a known delivery watermark
14026            // (seeded by a drained adoption backfill, or inherited from a
14027            // filter shape that was live before): subscribe first, then fetch
14028            // the gap, so nothing lands between the two. Pop only after the
14029            // request succeeds; errors and cancellation retain retry intent.
14030            let event = self.backfill_reconnected_source(&source).await?;
14031            self.pending_source_backfills.pop_front();
14032            if let Some(event) = event {
14033                self.enqueue_event(event);
14034            }
14035        }
14036
14037        if let AlloySubscriberState::Active(streams) = &mut self.state {
14038            streams.retain_sources(&desired);
14039            if streams.is_empty() {
14040                self.state = AlloySubscriberState::Empty;
14041            }
14042        }
14043
14044        self.bump_stream_revision();
14045        self.sources_dirty = false;
14046        self.retire_unreferenced_filters();
14047        Ok(())
14048    }
14049
14050    fn install_source_stream(
14051        &mut self,
14052        source: SubscriberStreamSource,
14053        stream: BoxStream<'static, SubscriberEvent<N>>,
14054    ) {
14055        match &mut self.state {
14056            AlloySubscriberState::Active(streams) => {
14057                if streams.contains_source(&source) {
14058                    return;
14059                }
14060                streams.push(source, stream);
14061            }
14062            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
14063                let mut streams = SubscriberStreams::new();
14064                streams.push(source, stream);
14065                self.state = AlloySubscriberState::Active(streams);
14066            }
14067        }
14068        // A partially completed reconcile is still a topology change. Advance
14069        // the revision now rather than only at the final clean boundary.
14070        self.bump_stream_revision();
14071    }
14072
14073    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
14074        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
14075            if self.last_seen_log_blocks.contains_key(id))
14076    }
14077
14078    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
14079        if !self
14080            .pending_source_backfills
14081            .iter()
14082            .any(|pending| pending.same_key(&source))
14083        {
14084            self.pending_source_backfills.push_back(source);
14085        }
14086    }
14087
14088    /// Fetch queued adoption/continuity backfills, oldest first.
14089    ///
14090    /// An entry is consumed only after its `get_logs` fetch succeeds — a
14091    /// transient RPC failure surfaces the error and leaves the entry queued for
14092    /// the next poll, so a flaky request cannot silently discard the missed
14093    /// window the backfill exists to close. Open-ended backfills resolve their
14094    /// upper bound to the provider's current head before fetching, and every
14095    /// drained backfill advances the filter's delivery anchor to that bound —
14096    /// even a zero-log window — so the filter is reconnect-protected from then
14097    /// on. Draining pauses as soon as records are ready for delivery; remaining
14098    /// entries stay queued.
14099    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
14100        while let Some(queued) = self.pending_backfills.front() {
14101            // Owner was removed while its backfill was queued.
14102            let epoch = queued.epoch.clone();
14103            let owner = queued.owner.clone();
14104            let owner_exists = match (&epoch, &owner) {
14105                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
14106                (None, Some(owner)) => self.owner_interests(owner).is_some(),
14107                (None, None) => true,
14108            };
14109            if !owner_exists {
14110                self.pending_backfills.pop_front();
14111                continue;
14112            }
14113            let filters = queued.filters.clone();
14114            let backfill = queued.backfill;
14115
14116            let to_block = match backfill.end_block() {
14117                Some(to_block) => to_block,
14118                None => self
14119                    .provider
14120                    .get_block_number()
14121                    .await
14122                    .map_err(provider_error)?,
14123            };
14124            if to_block < backfill.start_block() {
14125                // An exclusive post-baseline range can be empty when the
14126                // provider is still exactly at the retained head. Consume the
14127                // work only after validating that head and seed the filter at
14128                // the proven baseline so reconnect catch-up starts at C + 1.
14129                let certified = if let Some(retained) = backfill.retained_anchor() {
14130                    let actual =
14131                        fetch_provider_block_ref::<P, N>(&self.provider, retained.number).await?;
14132                    if !block_ref_satisfies_expected(&actual, retained) {
14133                        return Err(SubscriberError::InvalidBackfill(format!(
14134                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
14135                            retained.number, retained.hash, actual.number, actual.hash
14136                        )));
14137                    }
14138                    if to_block < retained.number {
14139                        return Err(SubscriberError::InvalidBackfill(format!(
14140                            "backfill upper bound {to_block} precedes retained anchor {}",
14141                            retained.number
14142                        )));
14143                    }
14144                    Some(actual)
14145                } else {
14146                    None
14147                };
14148                self.pending_backfills.pop_front();
14149                for filter in &filters {
14150                    let source_id = self.log_source_id(filter);
14151                    if let Some(certified) = certified {
14152                        self.last_seen_log_blocks
14153                            .entry(source_id)
14154                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
14155                            .or_insert(certified.number);
14156                    }
14157                }
14158                if owner.is_none()
14159                    && let Some(certified) = certified
14160                {
14161                    self.pending_chain_controls
14162                        .push_back(global_backfill_barrier(backfill, certified));
14163                }
14164                if !self.pending_chain_controls.is_empty() {
14165                    break;
14166                }
14167                continue;
14168            }
14169
14170            let through = fetch_provider_block_ref::<P, N>(&self.provider, to_block).await?;
14171            let request_filters =
14172                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
14173            let retained = backfill.retained_anchor().copied().into_iter().collect();
14174            let SubscriberOwnerCatchup {
14175                mut logs,
14176                certified,
14177            } = fetch_owner_catchup::<&P, N>(
14178                &self.provider,
14179                request_filters,
14180                retained,
14181                through,
14182                SubscriberOwnerCatchupOptions {
14183                    target_preverified: true,
14184                    max_logs: self.config.max_pending_records,
14185                    max_log_bytes: self.config.max_backfill_log_bytes,
14186                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
14187                },
14188            )
14189            .await
14190            .map_err(lazy_backfill_error)?;
14191            logs.sort_by_key(|log| {
14192                (
14193                    log.block_number.unwrap_or_default(),
14194                    log.transaction_index.unwrap_or_default(),
14195                    log.log_index.unwrap_or_default(),
14196                )
14197            });
14198            logs.dedup();
14199            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
14200
14201            // Fetch succeeded: consume the entry, deliver, and advance the
14202            // complete filter group through one globally ordered window.
14203            self.pending_backfills.pop_front();
14204            if let Some(epoch) = epoch.as_ref() {
14205                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
14206            } else if let Some(owner) = owner.as_ref() {
14207                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
14208            } else {
14209                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
14210                self.pending_chain_controls
14211                    .push_back(global_backfill_barrier(backfill, certified));
14212            }
14213            for filter in &filters {
14214                let source_id = self.log_source_id(filter);
14215                let anchor = self
14216                    .last_seen_log_blocks
14217                    .entry(source_id)
14218                    .or_insert(certified.number);
14219                *anchor = (*anchor).max(certified.number);
14220            }
14221
14222            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
14223                break;
14224            }
14225        }
14226        Ok(())
14227    }
14228
14229    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
14230        match resolve_subscriber_transport(self.mode)? {
14231            SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()),
14232            SubscriberTransport::Polling => Ok(self.polling_stream_sources()),
14233        }
14234    }
14235
14236    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
14237        let mut sources = Vec::new();
14238        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
14239
14240        for filter in self.log_stream_filters() {
14241            let id = self.log_source_id(&filter);
14242            if let Some(anchor) = inherited_anchor {
14243                self.last_seen_log_blocks.entry(id).or_insert(anchor);
14244            }
14245            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
14246        }
14247
14248        if needs_pending_hash_stream(&self.interests) {
14249            sources.push(SubscriberStreamSource::PubSubPendingHashes);
14250        }
14251
14252        if needs_header_block_stream(&self.interests) {
14253            sources.push(SubscriberStreamSource::PubSubBlockHeaders);
14254        }
14255
14256        if self.config.preconfirmations != PreconfirmationMode::Disabled {
14257            match self.chain_id.and_then(flashblocks_adapter) {
14258                Some(FlashblocksAdapter::BaseNative) => {
14259                    sources.push(SubscriberStreamSource::BaseFlashblocks);
14260                    for filter in self.log_stream_filters() {
14261                        let id = self.log_source_id(&filter);
14262                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
14263                    }
14264                }
14265                Some(FlashblocksAdapter::OpPending) => {
14266                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
14267                }
14268                None => {}
14269            }
14270        }
14271
14272        sources
14273    }
14274
14275    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
14276        let mut sources = Vec::new();
14277
14278        for filter in self.log_stream_filters() {
14279            sources.push(SubscriberStreamSource::PollingLog { filter });
14280        }
14281
14282        if needs_pending_hash_stream(&self.interests) {
14283            sources.push(SubscriberStreamSource::PollingPendingHashes);
14284        }
14285
14286        if self.config.preconfirmations != PreconfirmationMode::Disabled
14287            && self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::OpPending)
14288        {
14289            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
14290        }
14291
14292        sources
14293    }
14294
14295    fn log_source_id(&mut self, filter: &Filter) -> usize {
14296        if let Some(id) = self.log_source_ids.get(filter) {
14297            return *id;
14298        }
14299
14300        let id = self.next_log_source_id;
14301        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
14302        self.log_source_ids.insert(filter.clone(), id);
14303        id
14304    }
14305
14306    async fn connect_source_stream(
14307        &mut self,
14308        source: SubscriberStreamSource,
14309    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14310        match source {
14311            SubscriberStreamSource::PubSubLog { id, filter } => {
14312                self.connect_pubsub_log_stream(id, filter).await
14313            }
14314            SubscriberStreamSource::BasePendingLog { id, filter } => {
14315                self.connect_base_pending_log_stream(id, filter).await
14316            }
14317            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
14318            SubscriberStreamSource::OpPendingFlashblocks => {
14319                self.connect_op_flashblock_tick_stream()
14320            }
14321            SubscriberStreamSource::PubSubPendingHashes => {
14322                self.connect_pubsub_pending_hash_stream().await
14323            }
14324            SubscriberStreamSource::PubSubBlockHeaders => {
14325                self.connect_pubsub_block_header_stream().await
14326            }
14327            SubscriberStreamSource::PollingLog { filter } => {
14328                self.connect_polling_log_stream(filter).await
14329            }
14330            SubscriberStreamSource::PollingPendingHashes => {
14331                self.connect_polling_pending_hash_stream().await
14332            }
14333        }
14334    }
14335
14336    async fn connect_pubsub_log_stream(
14337        &mut self,
14338        id: usize,
14339        filter: Filter,
14340    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14341        #[cfg(feature = "reactive-ws")]
14342        {
14343            let source = SubscriberStreamSource::PubSubLog {
14344                id,
14345                filter: filter.clone(),
14346            };
14347            let stream = self
14348                .provider
14349                .subscribe_logs(&filter)
14350                .channel_size(self.config.max_batch_size.max(1))
14351                .await
14352                .map_err(provider_error)?
14353                .into_stream()
14354                .map(move |log| SubscriberEvent::Log { source_id: id, log });
14355            Ok(stream_with_termination(stream, source))
14356        }
14357
14358        #[cfg(not(feature = "reactive-ws"))]
14359        {
14360            let _ = (id, filter);
14361            Err(SubscriberError::Unsupported(
14362                "AlloySubscriber pubsub mode requires the reactive-ws feature",
14363            ))
14364        }
14365    }
14366
14367    async fn connect_base_pending_log_stream(
14368        &mut self,
14369        id: usize,
14370        filter: Filter,
14371    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14372        #[cfg(feature = "reactive-ws")]
14373        {
14374            let source = SubscriberStreamSource::BasePendingLog {
14375                id,
14376                filter: filter.clone(),
14377            };
14378            let params = base_pending_log_filter(&filter)?;
14379            let stream = self
14380                .provider
14381                .subscribe::<_, Log>(("pendingLogs", params))
14382                .channel_size(self.config.max_batch_size.max(1))
14383                .await
14384                .map_err(provider_error)?
14385                .into_stream()
14386                .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
14387            Ok(stream_with_termination(stream, source))
14388        }
14389
14390        #[cfg(not(feature = "reactive-ws"))]
14391        {
14392            let _ = (id, filter);
14393            Err(SubscriberError::Unsupported(
14394                "Base Flashblocks require the reactive-ws feature",
14395            ))
14396        }
14397    }
14398
14399    async fn connect_base_flashblock_stream(
14400        &mut self,
14401    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14402        #[cfg(feature = "reactive-ws")]
14403        {
14404            let stream = self
14405                .provider
14406                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
14407                .channel_size(self.config.max_batch_size.max(1))
14408                .await
14409                .map_err(provider_error)?
14410                .into_stream()
14411                .map(SubscriberEvent::BaseFlashblock);
14412            Ok(stream_with_termination(
14413                stream,
14414                SubscriberStreamSource::BaseFlashblocks,
14415            ))
14416        }
14417
14418        #[cfg(not(feature = "reactive-ws"))]
14419        {
14420            Err(SubscriberError::Unsupported(
14421                "Base Flashblocks require the reactive-ws feature",
14422            ))
14423        }
14424    }
14425
14426    fn connect_op_flashblock_tick_stream(
14427        &self,
14428    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14429        let interval = tokio::time::interval(self.config.flashblock_poll_interval);
14430        let stream = stream::unfold(interval, |mut interval| async move {
14431            interval.tick().await;
14432            Some((SubscriberEvent::OpFlashblockTick, interval))
14433        });
14434        Ok(stream_with_termination(
14435            stream,
14436            SubscriberStreamSource::OpPendingFlashblocks,
14437        ))
14438    }
14439
14440    async fn connect_pubsub_pending_hash_stream(
14441        &mut self,
14442    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14443        #[cfg(feature = "reactive-ws")]
14444        {
14445            let stream = self
14446                .provider
14447                .subscribe_pending_transactions()
14448                .channel_size(self.config.max_batch_size.max(1))
14449                .await
14450                .map_err(provider_error)?
14451                .into_stream()
14452                .map(SubscriberEvent::PendingHash);
14453            Ok(stream_with_termination(
14454                stream,
14455                SubscriberStreamSource::PubSubPendingHashes,
14456            ))
14457        }
14458
14459        #[cfg(not(feature = "reactive-ws"))]
14460        {
14461            Err(SubscriberError::Unsupported(
14462                "AlloySubscriber pubsub mode requires the reactive-ws feature",
14463            ))
14464        }
14465    }
14466
14467    async fn connect_pubsub_block_header_stream(
14468        &mut self,
14469    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14470        #[cfg(feature = "reactive-ws")]
14471        {
14472            let stream = self
14473                .provider
14474                .subscribe_blocks()
14475                .channel_size(self.config.max_batch_size.max(1))
14476                .await
14477                .map_err(provider_error)?
14478                .into_stream()
14479                .map(SubscriberEvent::BlockHeader);
14480            Ok(stream_with_termination(
14481                stream,
14482                SubscriberStreamSource::PubSubBlockHeaders,
14483            ))
14484        }
14485
14486        #[cfg(not(feature = "reactive-ws"))]
14487        {
14488            Err(SubscriberError::Unsupported(
14489                "AlloySubscriber pubsub mode requires the reactive-ws feature",
14490            ))
14491        }
14492    }
14493
14494    async fn connect_polling_log_stream(
14495        &mut self,
14496        filter: Filter,
14497    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14498        #[cfg(feature = "reactive-polling")]
14499        {
14500            let source = SubscriberStreamSource::PollingLog {
14501                filter: filter.clone(),
14502            };
14503            let stream = self
14504                .provider
14505                .watch_logs(&filter)
14506                .await
14507                .map_err(provider_error)?
14508                .with_channel_size(self.config.max_batch_size.max(1))
14509                .into_stream()
14510                .map(SubscriberEvent::Logs);
14511            Ok(stream_with_termination(stream, source))
14512        }
14513
14514        #[cfg(not(feature = "reactive-polling"))]
14515        {
14516            let _ = filter;
14517            Err(SubscriberError::Unsupported(
14518                "AlloySubscriber polling mode requires the reactive-polling feature",
14519            ))
14520        }
14521    }
14522
14523    async fn connect_polling_pending_hash_stream(
14524        &mut self,
14525    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
14526        #[cfg(feature = "reactive-polling")]
14527        {
14528            let stream = self
14529                .provider
14530                .watch_pending_transactions()
14531                .await
14532                .map_err(provider_error)?
14533                .with_channel_size(self.config.max_batch_size.max(1))
14534                .into_stream()
14535                .map(SubscriberEvent::PendingHashes);
14536            Ok(stream_with_termination(
14537                stream,
14538                SubscriberStreamSource::PollingPendingHashes,
14539            ))
14540        }
14541
14542        #[cfg(not(feature = "reactive-polling"))]
14543        {
14544            Err(SubscriberError::Unsupported(
14545                "AlloySubscriber polling mode requires the reactive-polling feature",
14546            ))
14547        }
14548    }
14549
14550    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
14551        loop {
14552            let event = match &mut self.state {
14553                AlloySubscriberState::Active(streams) => streams.next().await,
14554                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
14555                    return Ok(None);
14556                }
14557            };
14558
14559            let Some(event) = event else {
14560                return Err(SubscriberError::Provider(
14561                    "Alloy subscriber streams terminated before the subscriber was stopped"
14562                        .to_owned(),
14563                ));
14564            };
14565
14566            match event {
14567                SubscriberEvent::StreamTerminated(source) => {
14568                    // Persist the missing-source intent before the first await.
14569                    // If a control command cancels this poll during reconnect,
14570                    // the next poll will reconcile the desired/live diff.
14571                    self.sources_dirty = true;
14572                    self.bump_stream_revision();
14573                    if source.is_flashblocks() {
14574                        self.reset_flashblock_tracking();
14575                    }
14576                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
14577                        self.sources_dirty = false;
14578                        if let Some(backfill_event) =
14579                            self.normalize_flashblock_event(backfill_event).await?
14580                        {
14581                            self.verify_event_log_blocks(&backfill_event).await?;
14582                            return Ok(Some(backfill_event));
14583                        }
14584                    }
14585                    self.sources_dirty = false;
14586                }
14587                event => {
14588                    let Some(event) = self.normalize_flashblock_event(event).await? else {
14589                        continue;
14590                    };
14591                    self.verify_event_log_blocks(&event).await?;
14592                    return Ok(Some(event));
14593                }
14594            }
14595        }
14596    }
14597
14598    async fn normalize_flashblock_event(
14599        &mut self,
14600        event: SubscriberEvent<N>,
14601    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
14602        match event {
14603            SubscriberEvent::BasePendingLog { source_id, log } => {
14604                let hash = log.block_hash.ok_or_else(|| {
14605                    SubscriberError::Provider(
14606                        "Base pendingLogs item is missing its partial block hash".into(),
14607                    )
14608                })?;
14609                let Some(flashblock) = self.flashblocks_by_hash.get(&hash).cloned() else {
14610                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
14611                        return Err(SubscriberError::ResourceExhausted(
14612                            "unmatched Base pendingLogs exceeded max_pending_records".into(),
14613                        ));
14614                    }
14615                    self.unmatched_pending_logs.push_back((source_id, log));
14616                    return Ok(None);
14617                };
14618                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
14619                Ok(Some(if logs.is_empty() {
14620                    SubscriberEvent::FlashblockObserved
14621                } else {
14622                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
14623                }))
14624            }
14625            SubscriberEvent::BaseFlashblock(payload) => {
14626                let (flashblock, recover_pending_snapshot) =
14627                    self.accept_base_flashblock(payload)?;
14628                let mut logs = Vec::new();
14629                let mut retained = VecDeque::new();
14630                while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() {
14631                    if log.block_hash == Some(flashblock.block_hash) {
14632                        let _ = source_id;
14633                        logs.push(log);
14634                    } else {
14635                        retained.push_back((source_id, log));
14636                    }
14637                }
14638                self.unmatched_pending_logs = retained;
14639
14640                if recover_pending_snapshot
14641                    && let Some(event) = self.fetch_pending_flashblock().await?
14642                {
14643                    return Ok(Some(event));
14644                }
14645                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
14646                Ok(Some(if logs.is_empty() {
14647                    SubscriberEvent::FlashblockObserved
14648                } else {
14649                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
14650                }))
14651            }
14652            SubscriberEvent::OpFlashblockTick => self.fetch_pending_flashblock().await,
14653            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
14654                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
14655                Ok(Some(if logs.is_empty() {
14656                    SubscriberEvent::FlashblockObserved
14657                } else {
14658                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
14659                }))
14660            }
14661            SubscriberEvent::FlashblockObserved => Ok(None),
14662            event => Ok(Some(event)),
14663        }
14664    }
14665
14666    fn accept_base_flashblock(
14667        &mut self,
14668        payload: BaseFlashblockWirePayload,
14669    ) -> Result<(FlashblockRef, bool), SubscriberError> {
14670        let provider = self.provider_ref.clone().ok_or({
14671            SubscriberError::InvalidConfig(
14672                "Flashblocks require a stable provider ref from a pinned provider lease",
14673            )
14674        })?;
14675
14676        let (flashblock, recover_pending_snapshot) = match payload {
14677            BaseFlashblockWirePayload::Indexed(payload) => {
14678                if payload.index == 0 {
14679                    let base = payload.base.clone().ok_or_else(|| {
14680                        SubscriberError::Provider(
14681                            "Base newFlashblocks index zero omitted its base header".into(),
14682                        )
14683                    })?;
14684                    self.base_flashblock_header = Some((payload.payload_id, base));
14685                }
14686
14687                let base = self
14688                    .base_flashblock_header
14689                    .as_ref()
14690                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
14691                    .map(|(_, base)| base);
14692                let block_number = base.map(|base| base.block_number).or_else(|| {
14693                    payload
14694                        .metadata
14695                        .as_ref()
14696                        .map(|metadata| metadata.block_number)
14697                });
14698                let block_number = block_number.ok_or_else(|| {
14699                    SubscriberError::Provider(
14700                        "Base newFlashblocks payload omitted both base and metadata block number"
14701                            .into(),
14702                    )
14703                })?;
14704                let flashblock = FlashblockRef {
14705                    provider,
14706                    payload_id: Some(payload.payload_id),
14707                    index: Some(payload.index),
14708                    block_number,
14709                    block_hash: payload.diff.block_hash,
14710                    parent_hash: base.map(|base| base.parent_hash),
14711                    state_root: Some(payload.diff.state_root),
14712                    timestamp: base.map(|base| base.timestamp),
14713                };
14714                let recover = match self.latest_preconfirmation.as_ref() {
14715                    Some(previous) if previous.same_payload(&flashblock) => {
14716                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
14717                        {
14718                            if current < previous {
14719                                return Ok((flashblock, false));
14720                            }
14721                            current > previous.saturating_add(1)
14722                        } else {
14723                            false
14724                        }
14725                    }
14726                    Some(_) => payload.index != 0,
14727                    None => payload.index != 0,
14728                };
14729                (flashblock, recover)
14730            }
14731            BaseFlashblockWirePayload::Block(payload) => {
14732                let index = self
14733                    .latest_preconfirmation
14734                    .as_ref()
14735                    .filter(|previous| {
14736                        previous.block_number == payload.number
14737                            && previous.parent_hash == Some(payload.parent_hash)
14738                    })
14739                    .and_then(|previous| previous.index)
14740                    .map_or(0, |index| index.saturating_add(1));
14741                (
14742                    FlashblockRef {
14743                        provider,
14744                        payload_id: None,
14745                        index: Some(index),
14746                        block_number: payload.number,
14747                        block_hash: payload.hash,
14748                        parent_hash: Some(payload.parent_hash),
14749                        state_root: Some(payload.state_root),
14750                        timestamp: Some(payload.timestamp),
14751                    },
14752                    false,
14753                )
14754            }
14755        };
14756
14757        self.flashblocks_by_hash
14758            .insert(flashblock.block_hash, flashblock.clone());
14759        self.flashblock_hash_order.push_back(flashblock.block_hash);
14760        while self.flashblock_hash_order.len() > 64 {
14761            if let Some(hash) = self.flashblock_hash_order.pop_front() {
14762                self.flashblocks_by_hash.remove(&hash);
14763            }
14764        }
14765        Ok((flashblock, recover_pending_snapshot))
14766    }
14767
14768    async fn fetch_pending_flashblock(
14769        &mut self,
14770    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
14771        let latest = self
14772            .provider
14773            .get_block_number()
14774            .await
14775            .map_err(provider_error)?;
14776        let Some(block) = self
14777            .provider
14778            .get_block_by_number(BlockNumberOrTag::Pending)
14779            .await
14780            .map_err(provider_error)?
14781        else {
14782            if self.config.preconfirmations == PreconfirmationMode::Required {
14783                return Err(SubscriberError::Provider(
14784                    "Flashblocks provider returned no pending block".into(),
14785                ));
14786            }
14787            return Ok(None);
14788        };
14789        let header = block.header();
14790        if header.number() <= latest {
14791            if self.config.preconfirmations == PreconfirmationMode::Required {
14792                return Err(SubscriberError::Provider(
14793                    "Flashblocks provider pending state did not advance beyond the canonical head"
14794                        .into(),
14795                ));
14796            }
14797            return Ok(None);
14798        }
14799
14800        let provider = self.provider_ref.clone().ok_or({
14801            SubscriberError::InvalidConfig(
14802                "Flashblocks require a stable provider ref from a pinned provider lease",
14803            )
14804        })?;
14805        let parent_hash = Some(header.parent_hash());
14806        let index = self.latest_preconfirmation.as_ref().and_then(|previous| {
14807            (previous.block_number == header.number() && previous.parent_hash == parent_hash)
14808                .then(|| previous.index.unwrap_or(0).saturating_add(1))
14809        });
14810        let flashblock = FlashblockRef {
14811            provider,
14812            payload_id: None,
14813            index: Some(index.unwrap_or(0)),
14814            block_number: header.number(),
14815            block_hash: header.hash(),
14816            parent_hash,
14817            state_root: Some(header.state_root()),
14818            timestamp: Some(header.timestamp()),
14819        };
14820        if self
14821            .latest_preconfirmation
14822            .as_ref()
14823            .is_some_and(|previous| {
14824                previous.block_hash == flashblock.block_hash
14825                    && previous.block_number == flashblock.block_number
14826            })
14827        {
14828            return Ok(None);
14829        }
14830
14831        let logs = self.fetch_pending_logs().await?;
14832        let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
14833        Ok(Some(if logs.is_empty() {
14834            SubscriberEvent::FlashblockObserved
14835        } else {
14836            SubscriberEvent::PreconfirmedLogs { flashblock, logs }
14837        }))
14838    }
14839
14840    async fn fetch_pending_logs(&mut self) -> Result<Vec<Log>, SubscriberError> {
14841        let mut logs = Vec::new();
14842        for filter in self.log_stream_filters() {
14843            let filter = filter
14844                .from_block(BlockNumberOrTag::Pending)
14845                .to_block(BlockNumberOrTag::Pending);
14846            logs.extend(
14847                self.provider
14848                    .get_logs(&filter)
14849                    .await
14850                    .map_err(provider_error)?,
14851            );
14852        }
14853        Ok(logs)
14854    }
14855
14856    fn filter_preconfirmed_logs(
14857        &mut self,
14858        flashblock: &FlashblockRef,
14859        mut logs: Vec<Log>,
14860    ) -> Result<Vec<Log>, SubscriberError> {
14861        if self
14862            .latest_preconfirmation
14863            .as_ref()
14864            .is_none_or(|previous| !previous.same_payload(flashblock))
14865        {
14866            self.preconfirmed_seen_logs.clear();
14867        }
14868        if let Some(previous) = self.latest_preconfirmation.as_ref()
14869            && previous.same_payload(flashblock)
14870            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
14871            && current_index < previous_index
14872        {
14873            return Ok(Vec::new());
14874        }
14875        self.latest_preconfirmation = Some(flashblock.clone());
14876
14877        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
14878        let mut filtered = Vec::new();
14879        for log in logs {
14880            if log.removed
14881                || log.block_number != Some(flashblock.block_number)
14882                || log.block_hash != Some(flashblock.block_hash)
14883            {
14884                return Err(SubscriberError::Provider(
14885                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
14886                ));
14887            }
14888            let transaction_hash = log.transaction_hash.ok_or_else(|| {
14889                SubscriberError::Provider(
14890                    "pre-confirmed log is missing its transaction hash".into(),
14891                )
14892            })?;
14893            let log_index = log.log_index.ok_or_else(|| {
14894                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
14895            })?;
14896            if self
14897                .preconfirmed_seen_logs
14898                .insert((transaction_hash, log_index))
14899                && log_matches_any_interest(&log, &self.interests)
14900            {
14901                filtered.push(log);
14902            }
14903        }
14904        Ok(filtered)
14905    }
14906
14907    async fn verify_event_log_blocks(
14908        &mut self,
14909        event: &SubscriberEvent<N>,
14910    ) -> Result<(), SubscriberError> {
14911        if !self.config.verify_log_block_context {
14912            return Ok(());
14913        }
14914        match event {
14915            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
14916            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
14917                for log in logs {
14918                    self.verify_log_block_context(log).await?;
14919                }
14920                Ok(())
14921            }
14922            SubscriberEvent::BlockHeader(_)
14923            | SubscriberEvent::PendingHash(_)
14924            | SubscriberEvent::PendingHashes(_)
14925            | SubscriberEvent::BasePendingLog { .. }
14926            | SubscriberEvent::BaseFlashblock(_)
14927            | SubscriberEvent::OpFlashblockTick
14928            | SubscriberEvent::PreconfirmedLogs { .. }
14929            | SubscriberEvent::FlashblockObserved
14930            | SubscriberEvent::StreamTerminated(_) => Ok(()),
14931        }
14932    }
14933
14934    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
14935        if log.removed {
14936            return Ok(());
14937        }
14938        let number = log.block_number.ok_or_else(|| {
14939            SubscriberError::Provider(
14940                "canonical log is missing its block number during context verification".into(),
14941            )
14942        })?;
14943        let hash = log.block_hash.ok_or_else(|| {
14944            SubscriberError::Provider(
14945                "canonical log is missing its block hash during context verification".into(),
14946            )
14947        })?;
14948        let key = (number, hash);
14949        if self.verified_log_blocks.contains_key(&key) {
14950            return Ok(());
14951        }
14952        let provider = self
14953            .log_verification_provider
14954            .as_ref()
14955            .unwrap_or(&self.provider);
14956        let block = provider
14957            .get_block_by_number(BlockNumberOrTag::Number(number))
14958            .await
14959            .map_err(provider_error)?
14960            .ok_or_else(|| {
14961                SubscriberError::Provider(format!(
14962                    "canonical log block {number} is unavailable during context verification"
14963                ))
14964            })?;
14965        let header = block.header();
14966        let verified = BlockRef {
14967            number: header.number(),
14968            hash: header.hash(),
14969            parent_hash: Some(header.parent_hash()),
14970            timestamp: Some(header.timestamp()),
14971        };
14972        if verified.number != number
14973            || verified.hash != hash
14974            || log
14975                .block_timestamp
14976                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
14977        {
14978            return Err(SubscriberError::Provider(format!(
14979                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
14980            )));
14981        }
14982        self.verified_log_blocks.insert(key, verified);
14983        self.verified_log_block_order.push_back(key);
14984        let capacity = self.config.reconnect.dedupe_window.max(1);
14985        while self.verified_log_block_order.len() > capacity {
14986            if let Some(evicted) = self.verified_log_block_order.pop_front() {
14987                self.verified_log_blocks.remove(&evicted);
14988            }
14989        }
14990        Ok(())
14991    }
14992
14993    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
14994        self.enqueue_event_with_excluded_owners(event, None);
14995    }
14996
14997    fn buffer_reconcile_event_for_owners(
14998        &mut self,
14999        event: &SubscriberEvent<N>,
15000        target_epochs: &HashSet<SubscriberOwnerEpoch>,
15001    ) {
15002        match event {
15003            SubscriberEvent::Log { log, .. } => {
15004                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
15005            }
15006            SubscriberEvent::BackfilledLogs { logs, .. } => {
15007                for log in logs {
15008                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
15009                }
15010            }
15011            SubscriberEvent::Logs(logs) => {
15012                for log in logs {
15013                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
15014                }
15015            }
15016            SubscriberEvent::BlockHeader(_)
15017            | SubscriberEvent::PendingHash(_)
15018            | SubscriberEvent::PendingHashes(_)
15019            | SubscriberEvent::BasePendingLog { .. }
15020            | SubscriberEvent::BaseFlashblock(_)
15021            | SubscriberEvent::OpFlashblockTick
15022            | SubscriberEvent::PreconfirmedLogs { .. }
15023            | SubscriberEvent::FlashblockObserved
15024            | SubscriberEvent::StreamTerminated(_) => {}
15025        }
15026    }
15027
15028    fn buffer_reconcile_log_for_owners(
15029        &mut self,
15030        log: &Log,
15031        source: InputSource,
15032        target_epochs: &HashSet<SubscriberOwnerEpoch>,
15033    ) {
15034        let record = self.with_chain_id(log_input_record(log.clone(), source));
15035        let owners = self
15036            .staged_owners_for_record(&record)
15037            .into_iter()
15038            .filter(|owner| target_epochs.contains(owner))
15039            .collect::<Vec<_>>();
15040        if !owners.is_empty() {
15041            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
15042        }
15043    }
15044
15045    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
15046        let mut retained = VecDeque::new();
15047        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
15048            let mut promoted = Vec::new();
15049            buffered.owners.retain(|owner| {
15050                if target_epochs.contains(owner) {
15051                    promoted.push(owner.clone());
15052                    false
15053                } else {
15054                    true
15055                }
15056            });
15057            if promoted.is_empty() {
15058                retained.push_back(buffered);
15059                continue;
15060            }
15061            let promoted_record = if buffered.owners.is_empty() {
15062                buffered.record
15063            } else {
15064                let record = buffered.record.clone();
15065                retained.push_back(buffered);
15066                record
15067            };
15068            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
15069        }
15070        self.pending_reconcile_owner_records = retained;
15071    }
15072
15073    fn seed_reconciled_filter_anchors(
15074        &mut self,
15075        plans: &[SubscriberOwnerReconcilePlan<N>],
15076        through: u64,
15077    ) {
15078        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
15079            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
15080                continue;
15081            };
15082            let anchor = self
15083                .last_seen_log_blocks
15084                .entry(source_id)
15085                .or_insert(through);
15086            *anchor = (*anchor).max(through);
15087        }
15088    }
15089
15090    fn enqueue_event_excluding_owners(
15091        &mut self,
15092        event: SubscriberEvent<N>,
15093        excluded: &HashSet<SubscriberOwnerEpoch>,
15094    ) {
15095        self.enqueue_event_with_excluded_owners(event, Some(excluded));
15096    }
15097
15098    fn enqueue_event_with_excluded_owners(
15099        &mut self,
15100        event: SubscriberEvent<N>,
15101        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
15102    ) {
15103        match event {
15104            SubscriberEvent::Log { source_id, log } => {
15105                if log_matches_any_interest(&log, &self.interests) {
15106                    let record = log_input_record(log, InputSource::Subscription);
15107                    self.note_log_block(source_id, &record);
15108                    self.enqueue_record_with_excluded_owners(record, excluded);
15109                }
15110            }
15111            SubscriberEvent::BackfilledLogs { source_id, logs } => {
15112                self.enqueue_backfilled_logs_with_excluded_owners(
15113                    logs,
15114                    Some(source_id),
15115                    None,
15116                    None,
15117                    excluded,
15118                );
15119            }
15120            SubscriberEvent::Logs(logs) => {
15121                for log in logs {
15122                    if log_matches_any_interest(&log, &self.interests) {
15123                        self.enqueue_record_with_excluded_owners(
15124                            log_input_record(log, InputSource::Poll),
15125                            excluded,
15126                        );
15127                    }
15128                }
15129            }
15130            SubscriberEvent::BlockHeader(header) => {
15131                if needs_header_block_stream(&self.interests) {
15132                    let record = block_header_input_record::<N>(header);
15133                    self.enqueue_record_with_excluded_owners(record, excluded);
15134                }
15135            }
15136            SubscriberEvent::PendingHash(hash) => {
15137                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
15138                self.enqueue_record_with_excluded_owners(record, excluded);
15139            }
15140            SubscriberEvent::PendingHashes(hashes) => {
15141                for hash in hashes {
15142                    self.enqueue_record_with_excluded_owners(
15143                        pending_hash_input_record::<N>(hash, InputSource::Poll),
15144                        excluded,
15145                    );
15146                }
15147            }
15148            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
15149                for log in logs {
15150                    let record = self
15151                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
15152                    self.push_pending_record(SubscriberInputRecord {
15153                        record,
15154                        scope: SubscriberInputScope::Preconfirmed,
15155                    });
15156                }
15157            }
15158            SubscriberEvent::BasePendingLog { .. }
15159            | SubscriberEvent::BaseFlashblock(_)
15160            | SubscriberEvent::OpFlashblockTick
15161            | SubscriberEvent::FlashblockObserved => {}
15162            SubscriberEvent::StreamTerminated(_) => {}
15163        }
15164    }
15165
15166    fn enqueue_backfilled_logs(
15167        &mut self,
15168        logs: Vec<Log>,
15169        source_id: Option<usize>,
15170        owner: Option<&SubscriberOwnerEpoch>,
15171        range: Option<SubscriberBackfill>,
15172    ) {
15173        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
15174    }
15175
15176    fn enqueue_backfilled_logs_with_excluded_owners(
15177        &mut self,
15178        logs: Vec<Log>,
15179        source_id: Option<usize>,
15180        owner: Option<&SubscriberOwnerEpoch>,
15181        range: Option<SubscriberBackfill>,
15182        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
15183    ) {
15184        for log in logs {
15185            if range.as_ref().is_some_and(|range| {
15186                log.block_number.is_some_and(|block| {
15187                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
15188                })
15189            }) {
15190                continue;
15191            }
15192            let matches = match owner {
15193                Some(epoch) => self
15194                    .owned_interests
15195                    .iter()
15196                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
15197                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
15198                None => log_matches_any_interest(&log, &self.interests),
15199            };
15200            if matches {
15201                let record = log_input_record(log, InputSource::Backfill);
15202                if let Some(epoch) = owner {
15203                    self.enqueue_owner_record(record, epoch.clone());
15204                } else {
15205                    if let Some(source_id) = source_id {
15206                        self.note_log_block(source_id, &record);
15207                    }
15208                    self.enqueue_record_with_excluded_owners(record, excluded);
15209                }
15210            }
15211        }
15212    }
15213
15214    fn enqueue_compat_owner_backfilled_logs(
15215        &mut self,
15216        logs: Vec<Log>,
15217        owner: &HandlerId,
15218        range: SubscriberBackfill,
15219    ) {
15220        let interests = self
15221            .owned_interests
15222            .iter()
15223            .find(|entry| {
15224                &entry.owner == owner
15225                    && entry.epoch.is_none()
15226                    && entry.state == SubscriberOwnerState::Active
15227            })
15228            .map(|entry| entry.interests.clone());
15229        let Some(interests) = interests else {
15230            return;
15231        };
15232        for log in logs {
15233            if log.block_number.is_some_and(|block| {
15234                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
15235            }) || !log_matches_any_interest(&log, &interests)
15236            {
15237                continue;
15238            }
15239            let record = log_input_record(log, InputSource::Backfill);
15240            self.enqueue_compat_owner_record(record, owner.clone());
15241        }
15242    }
15243
15244    async fn reconnect_source_stream(
15245        &mut self,
15246        source: SubscriberStreamSource,
15247    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15248        if !source.is_pubsub() {
15249            return Err(stream_terminated_error(&source));
15250        }
15251
15252        if !self.config.reconnect.enabled {
15253            return Err(SubscriberError::Provider(format!(
15254                "Alloy subscriber {} stream terminated and reconnect is disabled",
15255                source.label()
15256            )));
15257        }
15258
15259        let mut attempts = 0usize;
15260        let mut delay = self.config.reconnect.initial_delay;
15261        let mut retry_delay = self.config.reconnect.retry_delay;
15262
15263        loop {
15264            attempts = attempts.saturating_add(1);
15265            if !delay.is_zero() {
15266                tokio::time::sleep(delay).await;
15267            }
15268
15269            match self.reconnect_source_once(source.clone()).await {
15270                Ok(backfill_event) => return Ok(backfill_event),
15271                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
15272                    return Err(SubscriberError::Provider(format!(
15273                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
15274                        source.label()
15275                    )));
15276                }
15277                Err(error) => {
15278                    tracing::warn!(
15279                        stream = source.label(),
15280                        attempts,
15281                        error = %error,
15282                        "Alloy subscriber reconnect attempt failed"
15283                    );
15284                    delay = retry_delay;
15285                    retry_delay =
15286                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
15287                }
15288            }
15289        }
15290    }
15291
15292    async fn reconnect_source_once(
15293        &mut self,
15294        source: SubscriberStreamSource,
15295    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15296        if matches!(
15297            &self.state,
15298            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
15299        ) {
15300            // A prior attempt installed the stream before its catch-up await
15301            // failed or was cancelled. Retry only the unfinished historical
15302            // window; reconnecting again would create a duplicate live source.
15303            let backfill_event = self.backfill_reconnected_source(&source).await?;
15304            self.pending_source_backfills
15305                .retain(|pending| !pending.same_key(&source));
15306            return Ok(backfill_event);
15307        }
15308        let stream = self.connect_source_stream(source.clone()).await?;
15309        if !matches!(self.state, AlloySubscriberState::Active(_)) {
15310            return Err(SubscriberError::Provider(
15311                "Alloy subscriber state changed before reconnect completed".to_owned(),
15312            ));
15313        }
15314        self.install_source_stream(source.clone(), stream);
15315        if self.source_requires_backfill(&source) {
15316            self.queue_source_backfill(source.clone());
15317        }
15318        let backfill_event = self.backfill_reconnected_source(&source).await?;
15319        self.pending_source_backfills
15320            .retain(|pending| !pending.same_key(&source));
15321
15322        Ok(backfill_event)
15323    }
15324
15325    async fn backfill_reconnected_source(
15326        &mut self,
15327        source: &SubscriberStreamSource,
15328    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15329        if source.is_flashblocks() {
15330            return self.fetch_pending_flashblock().await;
15331        }
15332        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
15333            return Ok(None);
15334        };
15335        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
15336            return Ok(None);
15337        };
15338
15339        let latest = self
15340            .provider
15341            .get_block_number()
15342            .await
15343            .map_err(provider_error)?;
15344        if latest < from_block {
15345            return Ok(None);
15346        }
15347
15348        let logs = self
15349            .provider
15350            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
15351            .await
15352            .map_err(provider_error)?;
15353        Ok(Some(SubscriberEvent::BackfilledLogs {
15354            source_id: *id,
15355            logs,
15356        }))
15357    }
15358
15359    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
15360        if let Some(block) = record.context.block.as_ref() {
15361            self.last_seen_log_blocks.insert(source_id, block.number);
15362        }
15363    }
15364
15365    fn enqueue_record_with_excluded_owners(
15366        &mut self,
15367        record: ReactiveInputRecord<N>,
15368        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
15369    ) {
15370        let record = self.with_chain_id(record);
15371        let mut owners = self.staged_owners_for_record(&record);
15372        if let Some(excluded) = excluded {
15373            owners.retain(|owner| !excluded.contains(owner));
15374        }
15375        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
15376        let owners = self.filter_recent_owner_duplicates(&record, owners);
15377        let compatibility_owners = self.compatibility_owners_for_record(&record);
15378        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
15379            .into_iter()
15380            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
15381        if canonical_duplicate {
15382            if !owners.is_empty() {
15383                self.push_pending_record(SubscriberInputRecord {
15384                    record: record.clone(),
15385                    scope: SubscriberInputScope::OwnerOnly { owners },
15386                });
15387            }
15388            if !newly_served.is_empty() {
15389                for owner in &newly_served {
15390                    self.remember_compatibility_owner_record(&record, owner);
15391                }
15392                self.push_pending_record(SubscriberInputRecord {
15393                    record,
15394                    scope: SubscriberInputScope::OwnerOnlyHandlers {
15395                        owners: newly_served,
15396                    },
15397                });
15398            }
15399            return;
15400        }
15401        self.remember_record(&record);
15402        for owner in already_served.iter().chain(&newly_served) {
15403            self.remember_compatibility_owner_record(&record, owner);
15404        }
15405        self.push_pending_record(SubscriberInputRecord {
15406            record,
15407            scope: if already_served.is_empty() {
15408                SubscriberInputScope::Canonical { owners }
15409            } else {
15410                SubscriberInputScope::CanonicalResidual {
15411                    owners,
15412                    excluded: already_served,
15413                }
15414            },
15415        });
15416    }
15417
15418    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
15419        let record = self.with_chain_id(record);
15420        if self.compatibility_owner_has_seen(&record, &owner) {
15421            return;
15422        }
15423        self.remember_compatibility_owner_record(&record, &owner);
15424        self.push_pending_record(SubscriberInputRecord {
15425            record,
15426            scope: SubscriberInputScope::OwnerOnlyHandlers {
15427                owners: vec![owner],
15428            },
15429        });
15430    }
15431
15432    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
15433        self.owned_interests
15434            .iter()
15435            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
15436            .filter(|entry| {
15437                entry
15438                    .interests
15439                    .iter()
15440                    .any(|interest| interest_matches(interest, &record.input))
15441            })
15442            .map(|entry| entry.owner.clone())
15443            .collect()
15444    }
15445
15446    fn compatibility_owner_has_seen(
15447        &self,
15448        record: &ReactiveInputRecord<N>,
15449        owner: &HandlerId,
15450    ) -> bool {
15451        should_dedupe_record(record)
15452            && self
15453                .recent_compat_owner_input_ref_sets
15454                .get(owner)
15455                .is_some_and(|seen| seen.contains(&record.input_ref()))
15456    }
15457
15458    fn remember_compatibility_owner_record(
15459        &mut self,
15460        record: &ReactiveInputRecord<N>,
15461        owner: &HandlerId,
15462    ) {
15463        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
15464            return;
15465        }
15466        let input_ref = record.input_ref();
15467        let seen = self
15468            .recent_compat_owner_input_ref_sets
15469            .entry(owner.clone())
15470            .or_default();
15471        if !seen.insert(input_ref) {
15472            return;
15473        }
15474        let recent = self
15475            .recent_compat_owner_input_refs
15476            .entry(owner.clone())
15477            .or_default();
15478        recent.push_back(input_ref);
15479        while recent.len() > self.config.reconnect.dedupe_window {
15480            if let Some(evicted) = recent.pop_front() {
15481                seen.remove(&evicted);
15482            }
15483        }
15484    }
15485
15486    fn enqueue_owner_record(
15487        &mut self,
15488        record: ReactiveInputRecord<N>,
15489        owner: SubscriberOwnerEpoch,
15490    ) {
15491        self.enqueue_owner_record_for_owners(record, vec![owner]);
15492    }
15493
15494    fn enqueue_owner_record_for_owners(
15495        &mut self,
15496        record: ReactiveInputRecord<N>,
15497        owners: Vec<SubscriberOwnerEpoch>,
15498    ) {
15499        self.enqueue_owner_record_for_owners_inner(record, owners, true);
15500    }
15501
15502    fn enqueue_owner_record_for_owners_unmerged(
15503        &mut self,
15504        record: ReactiveInputRecord<N>,
15505        owners: Vec<SubscriberOwnerEpoch>,
15506    ) {
15507        self.enqueue_owner_record_for_owners_inner(record, owners, false);
15508    }
15509
15510    fn enqueue_owner_record_for_owners_inner(
15511        &mut self,
15512        record: ReactiveInputRecord<N>,
15513        owners: Vec<SubscriberOwnerEpoch>,
15514        merge_pending: bool,
15515    ) {
15516        let record = self.with_chain_id(record);
15517        let owners = self.filter_recent_owner_duplicates(&record, owners);
15518        if owners.is_empty() {
15519            return;
15520        }
15521        if merge_pending
15522            && should_dedupe_record(&record)
15523            && self.config.reconnect.dedupe_window != 0
15524        {
15525            let input_ref = record.input_ref();
15526            if let Some(pending) = self
15527                .pending_records
15528                .iter_mut()
15529                .rev()
15530                .find(|pending| pending.record.input_ref() == input_ref)
15531            {
15532                let pending_owners = match &mut pending.scope {
15533                    SubscriberInputScope::Canonical { owners }
15534                    | SubscriberInputScope::CanonicalResidual { owners, .. }
15535                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
15536                    SubscriberInputScope::OwnerOnlyHandlers { .. }
15537                    | SubscriberInputScope::Preconfirmed => None,
15538                };
15539                if let Some(pending_owners) = pending_owners {
15540                    for owner in owners {
15541                        if !pending_owners.contains(&owner) {
15542                            pending_owners.push(owner);
15543                        }
15544                    }
15545                    return;
15546                }
15547            }
15548        }
15549        self.push_pending_record(SubscriberInputRecord {
15550            record,
15551            scope: SubscriberInputScope::OwnerOnly { owners },
15552        });
15553    }
15554
15555    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
15556        if self.pending_record_count() >= self.config.max_pending_records {
15557            self.note_resource_error(format!(
15558                "pending record queues reached the configured limit of {}",
15559                self.config.max_pending_records
15560            ));
15561            return;
15562        }
15563        self.pending_records.push_back(record);
15564    }
15565
15566    fn ensure_pending_record_capacity(
15567        &mut self,
15568        additional: usize,
15569        operation: &str,
15570    ) -> Result<(), SubscriberError> {
15571        let required = self.pending_record_count().saturating_add(additional);
15572        if required > self.config.max_pending_records {
15573            self.note_resource_error(format!(
15574                "{operation} require {required} pending records, above the configured limit of {}",
15575                self.config.max_pending_records
15576            ));
15577            return self.check_resource_error();
15578        }
15579        Ok(())
15580    }
15581
15582    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
15583        if self.pending_record_count() >= self.config.max_pending_records {
15584            self.note_resource_error(format!(
15585                "pending record queues reached the configured limit of {}",
15586                self.config.max_pending_records
15587            ));
15588            return;
15589        }
15590        self.pending_reconcile_owner_records.push_back(record);
15591    }
15592
15593    fn pending_record_count(&self) -> usize {
15594        self.pending_records
15595            .len()
15596            .saturating_add(self.pending_reconcile_owner_records.len())
15597    }
15598
15599    fn note_resource_error(&mut self, message: String) {
15600        if self.resource_error.is_none() {
15601            self.resource_error = Some(message);
15602        }
15603    }
15604
15605    fn check_resource_error(&self) -> Result<(), SubscriberError> {
15606        match &self.resource_error {
15607            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
15608            None => Ok(()),
15609        }
15610    }
15611
15612    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
15613        record.context.chain_id = self.chain_id;
15614        if self.config.verify_log_block_context
15615            && let ReactiveInput::Log(log) = &record.input
15616            && !log.removed
15617            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
15618            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
15619        {
15620            record.context.block = Some(verified);
15621            record.context.chain_status = ChainStatus::Included {
15622                block: verified,
15623                confirmations: 0,
15624            };
15625        }
15626        record
15627    }
15628
15629    fn staged_owners_for_record(
15630        &self,
15631        record: &ReactiveInputRecord<N>,
15632    ) -> Vec<SubscriberOwnerEpoch> {
15633        self.owned_interests
15634            .iter()
15635            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
15636            .filter(|entry| {
15637                entry
15638                    .interests
15639                    .iter()
15640                    .any(|interest| interest_matches(interest, &record.input))
15641            })
15642            .filter_map(|entry| entry.epoch.clone())
15643            .collect()
15644    }
15645
15646    fn filter_recent_owner_duplicates(
15647        &mut self,
15648        record: &ReactiveInputRecord<N>,
15649        owners: Vec<SubscriberOwnerEpoch>,
15650    ) -> Vec<SubscriberOwnerEpoch> {
15651        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
15652            return owners;
15653        }
15654        let input_ref = record.input_ref();
15655        let window = self.config.reconnect.dedupe_window;
15656        owners
15657            .into_iter()
15658            .filter(|owner| {
15659                let seen = self
15660                    .recent_owner_input_ref_sets
15661                    .entry(owner.clone())
15662                    .or_default();
15663                if !seen.insert(input_ref) {
15664                    return false;
15665                }
15666                let recent = self
15667                    .recent_owner_input_refs
15668                    .entry(owner.clone())
15669                    .or_default();
15670                recent.push_back(input_ref);
15671                while recent.len() > window {
15672                    if let Some(evicted) = recent.pop_front() {
15673                        seen.remove(&evicted);
15674                    }
15675                }
15676                true
15677            })
15678            .collect()
15679    }
15680
15681    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
15682        if !should_dedupe_record(record) {
15683            return false;
15684        }
15685        self.recent_input_ref_set.contains(&record.input_ref())
15686    }
15687
15688    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
15689        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
15690            return;
15691        }
15692
15693        let input_ref = record.input_ref();
15694        if !self.recent_input_ref_set.insert(input_ref) {
15695            return;
15696        }
15697        self.recent_input_refs.push_back(input_ref);
15698
15699        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
15700            if let Some(evicted) = self.recent_input_refs.pop_front() {
15701                self.recent_input_ref_set.remove(&evicted);
15702            }
15703        }
15704    }
15705}
15706
15707fn stream_with_termination<N, S>(
15708    stream: S,
15709    source: SubscriberStreamSource,
15710) -> BoxStream<'static, SubscriberEvent<N>>
15711where
15712    N: Network + 'static,
15713    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
15714{
15715    stream
15716        .chain(stream::once(async move {
15717            SubscriberEvent::StreamTerminated(source)
15718        }))
15719        .boxed()
15720}
15721
15722fn aggregate_interests<N: Network>(
15723    base: &[ReactiveInterest<N>],
15724    owned: &[OwnedSubscriberInterests<N>],
15725) -> Vec<ReactiveInterest<N>> {
15726    base.iter()
15727        .cloned()
15728        .chain(
15729            owned
15730                .iter()
15731                .flat_map(|entry| entry.interests.iter().cloned()),
15732        )
15733        .collect()
15734}
15735
15736fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
15737    SubscriberError::Provider(format!(
15738        "Alloy subscriber {} stream terminated before the subscriber was stopped",
15739        source.label()
15740    ))
15741}
15742
15743fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
15744    config
15745        .max_attempts
15746        .is_some_and(|max_attempts| attempts >= max_attempts)
15747}
15748
15749fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
15750    if current.is_zero() {
15751        return current;
15752    }
15753    current.checked_mul(2).unwrap_or(max).min(max)
15754}
15755
15756fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
15757    match &record.input {
15758        ReactiveInput::Log(log) => {
15759            is_canonical_status(&record.context.chain_status) && !log.removed
15760        }
15761        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
15762        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
15763    }
15764}
15765
15766#[cfg(test)]
15767mod subscriber_helper_tests {
15768    use super::*;
15769    use alloy_provider::ProviderBuilder;
15770    use alloy_transport::mock::Asserter;
15771
15772    #[test]
15773    fn base_flashblock_wire_decodes_cumulative_block_shape() {
15774        let payload: BaseFlashblockWirePayload = serde_json::from_str(
15775            r#"{
15776                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15777                "number":"0x2ef403b",
15778                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
15779                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
15780                "timestamp":"0x6a68dd59",
15781                "transactions":[]
15782            }"#,
15783        )
15784        .expect("decode current Base newFlashblocks shape");
15785        let BaseFlashblockWirePayload::Block(payload) = payload else {
15786            panic!("expected cumulative block-shaped payload")
15787        };
15788        assert_eq!(payload.number, 49_233_979);
15789        assert_eq!(payload.timestamp, 1_785_257_305);
15790        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
15791        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
15792        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
15793    }
15794
15795    #[test]
15796    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
15797        let parent = BlockRef {
15798            number: 79,
15799            hash: B256::repeat_byte(0x79),
15800            parent_hash: Some(B256::repeat_byte(0x78)),
15801            timestamp: Some(1_700_000_079),
15802        };
15803        let old_tip = BlockRef {
15804            number: 80,
15805            hash: B256::repeat_byte(0x80),
15806            parent_hash: Some(parent.hash),
15807            timestamp: Some(1_700_000_080),
15808        };
15809        let replacement = BlockRef {
15810            hash: B256::repeat_byte(0xe0),
15811            parent_hash: Some(B256::repeat_byte(0xdf)),
15812            ..old_tip
15813        };
15814        let mut state =
15815            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
15816
15817        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
15818            .expect("replacement metadata is structurally valid")
15819            .expect("unknown parent is an observable rewind");
15820
15821        assert_eq!(rewind.common_ancestor, None);
15822        assert_eq!(rewind.dropped, vec![parent, old_tip]);
15823        assert_eq!(state.retained_canonical_history(), &[replacement]);
15824        assert_eq!(state.coverage_head(), Some(&replacement));
15825        assert_eq!(state.safe_head(), None);
15826        assert_eq!(state.finalized_head(), None);
15827    }
15828
15829    #[test]
15830    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
15831        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
15832        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
15833        let encoded = serde_json::to_string(&valid).expect("serialize id");
15834        assert_eq!(
15835            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
15836            valid
15837        );
15838        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
15839    }
15840
15841    fn rpc_log(removed: bool) -> Log {
15842        Log {
15843            inner: alloy_primitives::Log::new_unchecked(
15844                Address::repeat_byte(0x42),
15845                vec![B256::repeat_byte(0x01)],
15846                Bytes::new(),
15847            ),
15848            block_hash: Some(B256::repeat_byte(0x02)),
15849            block_number: Some(7),
15850            block_timestamp: Some(1_700_000_000),
15851            transaction_hash: Some(B256::repeat_byte(0x03)),
15852            transaction_index: Some(4),
15853            log_index: Some(5),
15854            removed,
15855        }
15856    }
15857
15858    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
15859        use alloy_consensus::SignableTransaction as _;
15860
15861        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
15862            chain_id,
15863            ..Default::default()
15864        }
15865        .into_signed(alloy_primitives::Signature::test_signature())
15866        .into();
15867        alloy_rpc_types_eth::Transaction {
15868            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
15869            block_hash: None,
15870            block_number: None,
15871            transaction_index: None,
15872            effective_gas_price: None,
15873        }
15874    }
15875
15876    #[cfg(feature = "reactive-ws")]
15877    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
15878        Log {
15879            inner: alloy_primitives::Log::new_unchecked(
15880                Address::repeat_byte(0x42),
15881                vec![B256::repeat_byte(0x01)],
15882                Bytes::new(),
15883            ),
15884            block_hash: Some(B256::repeat_byte(block_number as u8)),
15885            block_number: Some(block_number),
15886            block_timestamp: Some(1_700_000_000 + block_number),
15887            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
15888            transaction_index: Some(transaction_index),
15889            log_index: Some(log_index),
15890            removed: false,
15891        }
15892    }
15893
15894    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
15895    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
15896        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
15897            hash,
15898            inner: alloy_consensus::Header {
15899                number,
15900                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
15901                timestamp: 1_700_000_000 + number,
15902                ..Default::default()
15903            },
15904            total_difficulty: None,
15905            size: None,
15906        })
15907    }
15908
15909    #[tokio::test(flavor = "multi_thread")]
15910    #[cfg(feature = "reactive-ws")]
15911    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
15912        let stream_asserter = Asserter::new();
15913        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
15914        let verification_asserter = Asserter::new();
15915        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
15916        let verification_provider =
15917            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
15918        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
15919            provider,
15920            SubscriberMode::PubSub,
15921            SubscriberConfig {
15922                verify_log_block_context: true,
15923                ..SubscriberConfig::default()
15924            },
15925        )
15926        .with_log_verification_provider(verification_provider);
15927        let log = rpc_log_at(7, 0, 0);
15928
15929        subscriber
15930            .verify_log_block_context(&log)
15931            .await
15932            .expect("verify live log block");
15933        subscriber
15934            .verify_log_block_context(&log)
15935            .await
15936            .expect("reuse verified block cache");
15937        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
15938
15939        assert_eq!(
15940            record.context.block.expect("verified block").parent_hash,
15941            Some(B256::repeat_byte(6))
15942        );
15943        assert!(
15944            verification_asserter.read_q().is_empty(),
15945            "one provider lookup should verify every log in the same block"
15946        );
15947        assert!(
15948            stream_asserter.read_q().is_empty(),
15949            "verification must not use the high-volume stream provider"
15950        );
15951    }
15952
15953    #[tokio::test(flavor = "multi_thread")]
15954    async fn stream_with_termination_yields_terminal_source_marker() {
15955        let mut stream = stream_with_termination::<Ethereum, _>(
15956            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
15957                0xaa,
15958            ))]),
15959            SubscriberStreamSource::PubSubPendingHashes,
15960        );
15961
15962        assert!(matches!(
15963            stream.next().await,
15964            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
15965        ));
15966        assert!(matches!(
15967            stream.next().await,
15968            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
15969        ));
15970        assert!(stream.next().await.is_none());
15971    }
15972
15973    #[test]
15974    fn reconnect_delay_doubles_until_capped() {
15975        assert_eq!(
15976            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
15977            Duration::from_millis(500)
15978        );
15979        assert_eq!(
15980            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
15981            Duration::from_secs(1)
15982        );
15983        assert_eq!(
15984            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
15985            Duration::ZERO
15986        );
15987    }
15988
15989    #[test]
15990    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
15991        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
15992        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
15993
15994        assert!(should_dedupe_record(&included));
15995        assert!(!should_dedupe_record(&removed));
15996    }
15997
15998    #[test]
15999    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
16000        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
16001                                     timestamp: Option<u64>| {
16002            record.context.block.as_mut().expect("block").timestamp = timestamp;
16003            match &mut record.context.chain_status {
16004                ChainStatus::Included { block, .. }
16005                | ChainStatus::Safe { block }
16006                | ChainStatus::Finalized { block }
16007                | ChainStatus::Reorged {
16008                    dropped_from: block,
16009                } => block.timestamp = timestamp,
16010                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
16011                    panic!("log record is canonical")
16012                }
16013            }
16014        };
16015
16016        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
16017        let payload_timestamp = match &payload_only.input {
16018            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
16019            _ => unreachable!(),
16020        };
16021        set_context_timestamp(&mut payload_only, None);
16022        let mut context_only = payload_only.clone();
16023        if let ReactiveInput::Log(log) = &mut context_only.input {
16024            log.block_timestamp = None;
16025        }
16026        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
16027        assert!(matches!(
16028            dedupe_records(vec![payload_only, context_only]),
16029            Err(ReactiveError::InvalidInputRecord { .. })
16030        ));
16031
16032        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
16033        if let ReactiveInput::Log(log) = &mut partial.input {
16034            log.block_timestamp = None;
16035        }
16036        set_context_timestamp(&mut partial, None);
16037        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
16038        let deduped =
16039            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
16040        assert_eq!(deduped.len(), 1);
16041        deduped[0]
16042            .validated_identity()
16043            .expect("merged record remains coherent");
16044        let resolved = resolve_record_block_payload_metadata(
16045            &deduped[0],
16046            *canonical_record_block(&deduped[0]).expect("canonical"),
16047        )
16048        .expect("effective block");
16049        assert_eq!(resolved.timestamp, Some(payload_timestamp));
16050    }
16051
16052    #[test]
16053    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
16054        use alloy_rpc_types_eth::{Block, Header};
16055
16056        let block_ref = BlockRef {
16057            number: 7,
16058            hash: B256::repeat_byte(0x77),
16059            parent_hash: Some(B256::repeat_byte(0x66)),
16060            timestamp: Some(1_700_000_007),
16061        };
16062        let block = Block::empty(Header {
16063            hash: block_ref.hash,
16064            inner: alloy_consensus::Header {
16065                number: block_ref.number,
16066                parent_hash: block_ref.parent_hash.expect("parent"),
16067                timestamp: block_ref.timestamp.expect("timestamp"),
16068                ..Default::default()
16069            },
16070            total_difficulty: None,
16071            size: None,
16072        });
16073        let record = ReactiveInputRecord::<Ethereum>::new(
16074            ReactiveInput::FullBlock(block),
16075            ReactiveContext {
16076                chain_id: Some(1),
16077                source: InputSource::Subscription,
16078                chain_status: ChainStatus::Included {
16079                    block: block_ref,
16080                    confirmations: 0,
16081                },
16082                block: Some(block_ref),
16083                transaction_index: None,
16084                log_index: None,
16085            },
16086        );
16087
16088        assert!(!record.is_payload_deduplicable());
16089        assert!(!record.same_deduplicable_payload(&record));
16090        let retained = dedupe_scoped_records(vec![
16091            (
16092                record.clone(),
16093                DeliveryAudience::All,
16094                DeliveryScope::Canonical,
16095            ),
16096            (record, DeliveryAudience::All, DeliveryScope::Canonical),
16097        ])
16098        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
16099        assert_eq!(retained.len(), 2);
16100    }
16101
16102    #[test]
16103    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
16104        let pending_context = ReactiveContext {
16105            chain_id: Some(1),
16106            source: InputSource::Batch,
16107            chain_status: ChainStatus::Pending,
16108            block: None,
16109            transaction_index: None,
16110            log_index: None,
16111        };
16112        let mut included_pending = rpc_transaction(Some(1));
16113        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
16114        assert!(matches!(
16115            ReactiveInputRecord::<Ethereum>::new(
16116                ReactiveInput::PendingTx(included_pending),
16117                pending_context.clone(),
16118            )
16119            .validated_identity(),
16120            Err(ReactiveError::InvalidInputRecord { .. })
16121        ));
16122        assert!(matches!(
16123            ReactiveInputRecord::<Ethereum>::new(
16124                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
16125                pending_context,
16126            )
16127            .validated_identity(),
16128            Err(ReactiveError::InvalidInputRecord { .. })
16129        ));
16130
16131        let block_ref = BlockRef {
16132            number: 8,
16133            hash: B256::repeat_byte(0x88),
16134            parent_hash: Some(B256::repeat_byte(0x77)),
16135            timestamp: Some(1_700_000_008),
16136        };
16137        let header = alloy_rpc_types_eth::Header {
16138            hash: block_ref.hash,
16139            inner: alloy_consensus::Header {
16140                number: block_ref.number,
16141                parent_hash: block_ref.parent_hash.expect("parent"),
16142                timestamp: block_ref.timestamp.expect("timestamp"),
16143                ..Default::default()
16144            },
16145            total_difficulty: None,
16146            size: None,
16147        };
16148        let context = ReactiveContext {
16149            chain_id: Some(1),
16150            source: InputSource::Batch,
16151            chain_status: ChainStatus::Included {
16152                block: block_ref,
16153                confirmations: 0,
16154            },
16155            block: Some(block_ref),
16156            transaction_index: None,
16157            log_index: None,
16158        };
16159        for transaction in [
16160            alloy_rpc_types_eth::Transaction {
16161                block_hash: Some(B256::repeat_byte(0xff)),
16162                ..rpc_transaction(Some(1))
16163            },
16164            alloy_rpc_types_eth::Transaction {
16165                block_hash: Some(block_ref.hash),
16166                block_number: Some(block_ref.number),
16167                transaction_index: Some(1),
16168                ..rpc_transaction(Some(1))
16169            },
16170            rpc_transaction(Some(2)),
16171        ] {
16172            let block = alloy_rpc_types_eth::Block::new(
16173                header.clone(),
16174                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
16175            );
16176            assert!(matches!(
16177                ReactiveInputRecord::<Ethereum>::new(
16178                    ReactiveInput::FullBlock(block),
16179                    context.clone(),
16180                )
16181                .validated_identity(),
16182                Err(ReactiveError::InvalidInputRecord { .. })
16183            ));
16184        }
16185    }
16186
16187    #[test]
16188    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
16189        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16190        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16191            provider,
16192            SubscriberMode::Auto,
16193            SubscriberConfig::default(),
16194        );
16195        let owner = HandlerId::new("compat-owner");
16196        subscriber
16197            .add_interest_owner(
16198                owner.clone(),
16199                &[ReactiveInterest::Logs(LogInterest {
16200                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16201                    local_matcher: None,
16202                    route_key: None,
16203                })],
16204            )
16205            .unwrap();
16206        let log = rpc_log(false);
16207
16208        subscriber.enqueue_compat_owner_record(
16209            log_input_record(log.clone(), InputSource::Backfill),
16210            owner.clone(),
16211        );
16212        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
16213
16214        let batch = subscriber
16215            .drain_next_scoped_batch()
16216            .expect("owner catch-up and residual live copies");
16217        assert_eq!(batch.records.len(), 2);
16218        assert_eq!(
16219            batch.records[0].scope,
16220            SubscriberInputScope::OwnerOnlyHandlers {
16221                owners: vec![owner.clone()]
16222            }
16223        );
16224        assert_eq!(
16225            batch.records[1].scope,
16226            SubscriberInputScope::CanonicalResidual {
16227                owners: Vec::new(),
16228                excluded: vec![owner.clone()]
16229            }
16230        );
16231
16232        let reactive = batch.into_reactive_batch();
16233        assert_eq!(
16234            reactive.record_audience(0),
16235            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
16236        );
16237        assert_eq!(
16238            reactive.record_delivery_scope(0),
16239            Some(DeliveryScope::OwnerCatchup)
16240        );
16241        assert_eq!(
16242            reactive.record_audience(1),
16243            Some(&DeliveryAudience::AllExcept(vec![owner]))
16244        );
16245        assert_eq!(
16246            reactive.record_delivery_scope(1),
16247            Some(DeliveryScope::Canonical)
16248        );
16249    }
16250
16251    #[test]
16252    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
16253        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16254        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16255            provider,
16256            SubscriberMode::Auto,
16257            SubscriberConfig::default(),
16258        );
16259        let owner = HandlerId::new("replace-owner");
16260        let original = ReactiveInterest::Logs(LogInterest {
16261            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
16262            local_matcher: None,
16263            route_key: None,
16264        });
16265        let replacement_interest = ReactiveInterest::Logs(LogInterest {
16266            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16267            local_matcher: None,
16268            route_key: None,
16269        });
16270        let active = subscriber
16271            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
16272            .unwrap();
16273        assert!(subscriber.activate_interest_owner(&active));
16274        let replacement = subscriber
16275            .stage_interest_owner_replacement(
16276                owner,
16277                &[replacement_interest],
16278                SubscriberOwnerStart::Live,
16279            )
16280            .unwrap();
16281
16282        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
16283        assert_eq!(subscriber.interest_owner_state(&active), None);
16284        assert_eq!(
16285            subscriber.interest_owner_state(&replacement),
16286            Some(SubscriberOwnerState::Active)
16287        );
16288        assert_eq!(subscriber.registered_interests().len(), 1);
16289    }
16290
16291    #[test]
16292    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
16293        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16294        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16295            provider,
16296            SubscriberMode::Auto,
16297            SubscriberConfig::default(),
16298        );
16299        let owner = HandlerId::new("one-lifecycle");
16300        let interest = ReactiveInterest::Logs(LogInterest {
16301            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16302            local_matcher: None,
16303            route_key: None,
16304        });
16305        let epoch = subscriber
16306            .stage_interest_owner(
16307                owner.clone(),
16308                std::slice::from_ref(&interest),
16309                SubscriberOwnerStart::Live,
16310            )
16311            .expect("stage epoch owner");
16312
16313        assert!(matches!(
16314            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
16315            Err(SubscriberError::InvalidConfig(_))
16316        ));
16317        assert_eq!(
16318            subscriber.interest_owner_state(&epoch),
16319            Some(SubscriberOwnerState::Staged)
16320        );
16321        assert!(subscriber.abort_interest_owner(&epoch));
16322        subscriber
16323            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
16324            .expect("compatibility owner after epoch abort");
16325        assert!(matches!(
16326            subscriber.stage_interest_owner_replacement(
16327                owner,
16328                std::slice::from_ref(&interest),
16329                SubscriberOwnerStart::Live,
16330            ),
16331            Err(SubscriberOwnerError::AlreadyRegistered(_))
16332        ));
16333    }
16334
16335    #[test]
16336    fn pending_record_overflow_is_sticky_and_fail_closed() {
16337        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16338        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16339            provider,
16340            SubscriberMode::Polling,
16341            SubscriberConfig {
16342                max_pending_records: 1,
16343                ..SubscriberConfig::default()
16344            },
16345        );
16346        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
16347            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16348            local_matcher: None,
16349            route_key: None,
16350        })];
16351        subscriber.enqueue_event(SubscriberEvent::Log {
16352            source_id: 0,
16353            log: rpc_log(false),
16354        });
16355        let mut second = rpc_log(false);
16356        second.log_index = Some(6);
16357        second.transaction_hash = Some(B256::repeat_byte(0x04));
16358        subscriber.enqueue_event(SubscriberEvent::Log {
16359            source_id: 0,
16360            log: second,
16361        });
16362
16363        assert_eq!(subscriber.pending_records.len(), 1);
16364        assert!(matches!(
16365            subscriber.check_resource_error(),
16366            Err(SubscriberError::ResourceExhausted(_))
16367        ));
16368        subscriber.reset_delivery_state();
16369        assert!(subscriber.check_resource_error().is_ok());
16370    }
16371
16372    #[test]
16373    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
16374        let baseline = rpc_log(false);
16375        let fixed_bytes =
16376            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
16377                .expect("measure fixed log accounting");
16378        let mut large = baseline;
16379        large.inner = alloy_primitives::Log::new_unchecked(
16380            Address::repeat_byte(0x42),
16381            vec![B256::repeat_byte(0x01)],
16382            Bytes::from(vec![0u8; 256]),
16383        );
16384
16385        assert!(matches!(
16386            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
16387            Err(SubscriberError::ResourceExhausted(_))
16388        ));
16389    }
16390
16391    #[tokio::test(flavor = "multi_thread")]
16392    #[cfg(feature = "reactive-polling")]
16393    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
16394        use alloy_rpc_types_eth::{Block, Header};
16395
16396        let asserter = Asserter::new();
16397        let baseline = BlockRef {
16398            number: 6,
16399            hash: B256::repeat_byte(6),
16400            parent_hash: Some(B256::repeat_byte(5)),
16401            timestamp: Some(1_700_000_006),
16402        };
16403        let through = BlockRef {
16404            number: 7,
16405            hash: B256::repeat_byte(7),
16406            parent_hash: Some(baseline.hash),
16407            timestamp: Some(1_700_000_007),
16408        };
16409        let rpc_block = || -> Block {
16410            Block::empty(Header {
16411                hash: through.hash,
16412                inner: alloy_consensus::Header {
16413                    number: through.number,
16414                    parent_hash: through.parent_hash.expect("parent"),
16415                    timestamp: through.timestamp.expect("timestamp"),
16416                    ..Default::default()
16417                },
16418                total_difficulty: None,
16419                size: None,
16420            })
16421        };
16422        let mut historical = rpc_log(false);
16423        historical.block_hash = Some(through.hash);
16424        historical.block_timestamp = through.timestamp;
16425        asserter.push_success(&Some(rpc_block()));
16426        asserter.push_success(&vec![historical]);
16427        asserter.push_success(&Some(rpc_block()));
16428        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
16429        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16430            provider,
16431            SubscriberMode::Polling,
16432            SubscriberConfig {
16433                max_pending_records: 1,
16434                ..SubscriberConfig::default()
16435            },
16436        );
16437        subscriber.chain_id = Some(1);
16438        let interest = ReactiveInterest::Logs(LogInterest {
16439            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16440            local_matcher: None,
16441            route_key: None,
16442        });
16443        let epoch = subscriber
16444            .stage_interest_owner(
16445                HandlerId::new("capacity-owner"),
16446                std::slice::from_ref(&interest),
16447                SubscriberOwnerStart::PostBlock(baseline),
16448            )
16449            .expect("stage owner");
16450        // Isolate the commit-side capacity edge: the live queue acquired one
16451        // canonical record while the historical request was in flight.
16452        subscriber.sources_dirty = false;
16453        subscriber.state = AlloySubscriberState::Empty;
16454        subscriber.push_pending_record(SubscriberInputRecord {
16455            record: log_input_record(rpc_log(false), InputSource::Poll),
16456            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
16457        });
16458
16459        let error = subscriber
16460            .reconcile_interest_owner(&epoch, through)
16461            .await
16462            .expect_err("historical delivery cannot displace the queued live record");
16463        assert!(matches!(
16464            error,
16465            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
16466        ));
16467        assert!(subscriber.interest_owner_progress(&epoch).is_none());
16468        assert_eq!(subscriber.pending_records.len(), 1);
16469        assert!(matches!(
16470            subscriber.pending_records[0].scope,
16471            SubscriberInputScope::Canonical { .. }
16472        ));
16473    }
16474
16475    #[test]
16476    fn lazy_backfill_queue_capacity_failure_is_atomic() {
16477        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16478        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16479            provider,
16480            SubscriberMode::Auto,
16481            SubscriberConfig {
16482                max_pending_backfills: 1,
16483                ..SubscriberConfig::default()
16484            },
16485        );
16486        let interest = |address| {
16487            ReactiveInterest::Logs(LogInterest {
16488                provider_filter: Filter::new().address(address),
16489                local_matcher: None,
16490                route_key: None,
16491            })
16492        };
16493        subscriber
16494            .add_interest_owner_with_backfill(
16495                HandlerId::new("owner-a"),
16496                &[interest(Address::repeat_byte(0x41))],
16497                SubscriberBackfill::from_block(10),
16498            )
16499            .expect("first queued backfill");
16500
16501        let error = subscriber
16502            .add_interest_owner_with_backfill(
16503                HandlerId::new("owner-b"),
16504                &[interest(Address::repeat_byte(0x42))],
16505                SubscriberBackfill::from_block(10),
16506            )
16507            .expect_err("second backfill must exceed capacity");
16508
16509        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
16510        assert!(
16511            subscriber
16512                .owner_interests(&HandlerId::new("owner-b"))
16513                .is_none()
16514        );
16515        assert_eq!(subscriber.pending_backfills.len(), 1);
16516    }
16517
16518    #[test]
16519    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
16520        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16521        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16522            provider,
16523            SubscriberMode::Auto,
16524            SubscriberConfig {
16525                max_pending_backfills: 1,
16526                ..SubscriberConfig::default()
16527            },
16528        );
16529        let interest = |address| {
16530            ReactiveInterest::Logs(LogInterest {
16531                provider_filter: Filter::new().address(address),
16532                local_matcher: None,
16533                route_key: None,
16534            })
16535        };
16536        subscriber
16537            .add_interest_owner(
16538                HandlerId::new("crash-stale"),
16539                &[interest(Address::repeat_byte(0xee))],
16540            )
16541            .expect("seed stale owner");
16542        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
16543        subscriber.rebuild_registered_interests();
16544        subscriber.push_pending_record(SubscriberInputRecord {
16545            record: log_input_record(rpc_log(false), InputSource::Poll),
16546            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
16547        });
16548        let baseline = BlockRef {
16549            number: 100,
16550            hash: B256::repeat_byte(100),
16551            parent_hash: Some(B256::repeat_byte(99)),
16552            timestamp: Some(1_700_000_100),
16553        };
16554        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
16555
16556        let error = subscriber
16557            .replace_interest_owners_with_global_backfill(
16558                vec![
16559                    (
16560                        HandlerId::new("pool-a"),
16561                        vec![interest(Address::repeat_byte(0xa1))],
16562                    ),
16563                    (
16564                        HandlerId::new("pool-b"),
16565                        vec![ReactiveInterest::Logs(LogInterest {
16566                            // A distinct block option prevents provider-filter
16567                            // fan-in, exercising the two-unit capacity edge.
16568                            provider_filter: Filter::new()
16569                                .address(Address::repeat_byte(0xb2))
16570                                .from_block(7),
16571                            local_matcher: None,
16572                            route_key: None,
16573                        })],
16574                    ),
16575                ],
16576                backfill,
16577            )
16578            .expect_err("two backfills exceed atomic capacity");
16579        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
16580        assert!(
16581            subscriber
16582                .owner_interests(&HandlerId::new("crash-stale"))
16583                .is_some(),
16584            "failed replacement must preserve the prior topology"
16585        );
16586        assert!(
16587            subscriber
16588                .owner_interests(&HandlerId::new("pool-a"))
16589                .is_none()
16590        );
16591        assert_eq!(subscriber.base_interests.len(), 1);
16592        assert_eq!(subscriber.pending_records.len(), 1);
16593
16594        subscriber
16595            .replace_interest_owners_with_global_backfill(
16596                vec![(
16597                    HandlerId::new("pool-a"),
16598                    vec![interest(Address::repeat_byte(0xa1))],
16599                )],
16600                backfill,
16601            )
16602            .expect("replacement within capacity");
16603        assert!(
16604            subscriber
16605                .owner_interests(&HandlerId::new("crash-stale"))
16606                .is_none(),
16607            "successful exact replacement removes stale owners"
16608        );
16609        assert!(
16610            subscriber.base_interests.is_empty(),
16611            "successful exact replacement removes stale unowned interests"
16612        );
16613        assert!(
16614            subscriber.drain_next_scoped_batch().is_none(),
16615            "stale canonical delivery must not escape before C + 1 recovery"
16616        );
16617        assert!(
16618            subscriber
16619                .owner_interests(&HandlerId::new("pool-a"))
16620                .is_some()
16621        );
16622        assert_eq!(subscriber.pending_backfills.len(), 1);
16623        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
16624        assert!(
16625            subscriber.pending_backfills[0].owner.is_none(),
16626            "startup history must be global canonical catch-up, not owner-only"
16627        );
16628    }
16629
16630    #[test]
16631    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
16632        let baseline = BlockRef {
16633            number: u64::MAX,
16634            hash: B256::repeat_byte(0xff),
16635            parent_hash: None,
16636            timestamp: None,
16637        };
16638        assert!(matches!(
16639            SubscriberBackfill::after_canonical_block(baseline),
16640            Err(SubscriberError::InvalidConfig(_))
16641        ));
16642    }
16643
16644    #[tokio::test(flavor = "multi_thread")]
16645    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
16646        let asserter = Asserter::new();
16647        asserter.push_success(&101u64);
16648        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
16649        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
16650        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
16651        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16652            provider,
16653            SubscriberMode::Auto,
16654            SubscriberConfig::default(),
16655        );
16656        let baseline = BlockRef {
16657            number: 100,
16658            hash: B256::repeat_byte(0xaa),
16659            parent_hash: None,
16660            timestamp: None,
16661        };
16662        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
16663        subscriber
16664            .add_interest_owner_with_backfill(
16665                HandlerId::new("pool"),
16666                &[ReactiveInterest::Logs(LogInterest {
16667                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
16668                    local_matcher: None,
16669                    route_key: None,
16670                })],
16671                backfill,
16672            )
16673            .expect("queue post-baseline backfill");
16674
16675        let error = subscriber
16676            .drain_pending_backfills()
16677            .await
16678            .expect_err("provider branch differs at retained baseline");
16679        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
16680        assert_eq!(subscriber.pending_backfills.len(), 1);
16681        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
16682        assert!(subscriber.pending_records.is_empty());
16683    }
16684
16685    #[tokio::test(flavor = "multi_thread")]
16686    #[cfg(feature = "reactive-ws")]
16687    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
16688    {
16689        let asserter = Asserter::new();
16690        let retained = BlockRef {
16691            number: 10,
16692            hash: B256::repeat_byte(10),
16693            parent_hash: Some(B256::repeat_byte(9)),
16694            timestamp: Some(1_700_000_010),
16695        };
16696        let activation = BlockRef {
16697            number: 12,
16698            hash: B256::repeat_byte(12),
16699            parent_hash: Some(B256::repeat_byte(11)),
16700            timestamp: Some(1_700_000_012),
16701        };
16702
16703        // 257 distinct logical block options cross the 256-filter request
16704        // chunk boundary. Each window therefore makes two concurrent log
16705        // requests whose responses deliberately arrive in reverse order.
16706        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
16707        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
16708        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
16709        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
16710        asserter.push_success(&activation.number);
16711        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
16712        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
16713        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
16714        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
16715        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
16716        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
16717        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16718            provider,
16719            SubscriberMode::Auto,
16720            SubscriberConfig::default(),
16721        );
16722        let interests = (0..257)
16723            .map(|start| {
16724                ReactiveInterest::Logs(LogInterest {
16725                    provider_filter: Filter::new()
16726                        .address(Address::repeat_byte(0x42))
16727                        .event_signature(B256::repeat_byte(0x01))
16728                        .from_block(start),
16729                    local_matcher: None,
16730                    route_key: None,
16731                })
16732            })
16733            .collect::<Vec<_>>();
16734        subscriber
16735            .add_interest_owner_with_canonical_catchup(
16736                HandlerId::new("many-filters"),
16737                &interests,
16738                retained,
16739            )
16740            .expect("queue coordinated windows");
16741        assert_eq!(subscriber.pending_backfills.len(), 2);
16742        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
16743        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
16744
16745        subscriber
16746            .drain_pending_backfills()
16747            .await
16748            .expect("owner filter group");
16749        let owner = subscriber
16750            .drain_next_scoped_batch()
16751            .expect("owner ordered batch");
16752        assert_eq!(owner.records.len(), 2);
16753        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
16754        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
16755        assert!(
16756            owner.records.iter().all(|record| matches!(
16757                record.scope,
16758                SubscriberInputScope::OwnerOnlyHandlers { .. }
16759            ))
16760        );
16761
16762        subscriber
16763            .drain_pending_backfills()
16764            .await
16765            .expect("global filter group");
16766        let global = subscriber
16767            .drain_next_scoped_batch()
16768            .expect("global ordered batch");
16769        assert_eq!(global.records.len(), 2);
16770        assert_eq!(
16771            global.records[0].record.context.block.map(|b| b.number),
16772            Some(11)
16773        );
16774        assert_eq!(
16775            global.records[1].record.context.block.map(|b| b.number),
16776            Some(12)
16777        );
16778        assert!(
16779            global
16780                .records
16781                .iter()
16782                .all(|record| record.scope.is_canonical())
16783        );
16784        assert!(matches!(
16785            global.chain_controls.as_slice(),
16786            [ChainControl::Barrier {
16787                block: Some(block),
16788                ..
16789            }] if block == &activation
16790        ));
16791    }
16792
16793    #[tokio::test(flavor = "multi_thread")]
16794    #[cfg(feature = "reactive-ws")]
16795    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
16796        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16797        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16798            provider,
16799            SubscriberMode::PubSub,
16800            SubscriberConfig::default(),
16801        );
16802        subscriber.chain_id = Some(1);
16803        let interest = ReactiveInterest::Logs(LogInterest {
16804            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16805            local_matcher: None,
16806            route_key: None,
16807        });
16808        let owner_a = subscriber
16809            .stage_interest_owner(
16810                HandlerId::new("owner-a"),
16811                std::slice::from_ref(&interest),
16812                SubscriberOwnerStart::Live,
16813            )
16814            .unwrap();
16815        let owner_b = subscriber
16816            .stage_interest_owner(
16817                HandlerId::new("owner-b"),
16818                &[interest],
16819                SubscriberOwnerStart::Live,
16820            )
16821            .unwrap();
16822
16823        subscriber.enqueue_event(SubscriberEvent::Log {
16824            source_id: 0,
16825            log: rpc_log(false),
16826        });
16827        assert!(subscriber.abort_interest_owner(&owner_a));
16828
16829        let batch = subscriber
16830            .next_scoped_batch()
16831            .await
16832            .unwrap()
16833            .expect("shared canonical delivery remains queued");
16834        assert_eq!(batch.records.len(), 1);
16835        assert_eq!(
16836            batch.records[0].scope,
16837            SubscriberInputScope::Canonical {
16838                owners: vec![owner_b]
16839            }
16840        );
16841    }
16842
16843    #[tokio::test(flavor = "multi_thread")]
16844    #[cfg(feature = "reactive-ws")]
16845    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
16846        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16847        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16848            provider,
16849            SubscriberMode::PubSub,
16850            SubscriberConfig::default(),
16851        );
16852        subscriber.chain_id = Some(1);
16853        let epoch = subscriber
16854            .stage_interest_owner(
16855                HandlerId::new("owner"),
16856                &[ReactiveInterest::Logs(LogInterest {
16857                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16858                    local_matcher: None,
16859                    route_key: None,
16860                })],
16861                SubscriberOwnerStart::Live,
16862            )
16863            .unwrap();
16864        let log = rpc_log(false);
16865
16866        subscriber.enqueue_owner_record(
16867            log_input_record(log.clone(), InputSource::Backfill),
16868            epoch.clone(),
16869        );
16870        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
16871
16872        let batch = subscriber
16873            .next_scoped_batch()
16874            .await
16875            .unwrap()
16876            .expect("owner backfill and canonical live delivery");
16877        assert_eq!(batch.records.len(), 2);
16878        assert_eq!(
16879            batch.records[0].scope,
16880            SubscriberInputScope::OwnerOnly {
16881                owners: vec![epoch]
16882            }
16883        );
16884        assert_eq!(
16885            batch.records[1].scope,
16886            SubscriberInputScope::Canonical { owners: Vec::new() },
16887            "owner replay dedupe must not suppress the global live record"
16888        );
16889    }
16890
16891    #[tokio::test(flavor = "multi_thread")]
16892    #[cfg(feature = "reactive-polling")]
16893    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
16894        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16895        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16896            provider,
16897            SubscriberMode::Polling,
16898            SubscriberConfig {
16899                max_batch_size: 2,
16900                ..SubscriberConfig::default()
16901            },
16902        );
16903        let interest = ReactiveInterest::Logs(LogInterest {
16904            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
16905            local_matcher: None,
16906            route_key: None,
16907        });
16908        let epoch = subscriber
16909            .stage_interest_owner(
16910                HandlerId::new("owner"),
16911                std::slice::from_ref(&interest),
16912                SubscriberOwnerStart::Live,
16913            )
16914            .unwrap();
16915        subscriber.sources_dirty = false;
16916
16917        let mut duplicate = rpc_log(false);
16918        duplicate.transaction_hash = Some(B256::repeat_byte(1));
16919        duplicate.log_index = Some(0);
16920        let events = (0u8..10).map(|index| {
16921            let mut log = rpc_log(false);
16922            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
16923            log.log_index = Some(index as u64);
16924            SubscriberEvent::Log { source_id: 0, log }
16925        });
16926        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
16927        let mut streams = SubscriberStreams::new();
16928        streams.push(
16929            SubscriberStreamSource::PollingLog { filter },
16930            stream::iter(events).boxed(),
16931        );
16932        subscriber.state = AlloySubscriberState::Active(streams);
16933
16934        let mut polls = 0usize;
16935        let fetched_duplicate = duplicate.clone();
16936        let fetch = poll_fn(move |cx| {
16937            polls += 1;
16938            if polls > 10 {
16939                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
16940            } else {
16941                cx.waker().wake_by_ref();
16942                std::task::Poll::Pending
16943            }
16944        });
16945        let target_epochs = HashSet::from([epoch.clone()]);
16946        let fetched_duplicate = subscriber
16947            .drive_reconcile_fetch(fetch, &target_epochs)
16948            .await
16949            .unwrap();
16950        subscriber.enqueue_owner_record_for_owners_unmerged(
16951            log_input_record(fetched_duplicate, InputSource::Backfill),
16952            vec![epoch.clone()],
16953        );
16954        subscriber.promote_reconcile_owner_records(&target_epochs);
16955
16956        assert_eq!(subscriber.pending_records.len(), 20);
16957        assert!(subscriber.pending_records.iter().take(10).all(|record| {
16958            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
16959        }));
16960        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
16961            record.scope
16962                == SubscriberInputScope::OwnerOnly {
16963                    owners: vec![epoch.clone()],
16964                }
16965        }));
16966    }
16967
16968    #[tokio::test(flavor = "multi_thread")]
16969    #[cfg(feature = "reactive-polling")]
16970    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
16971        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
16972        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
16973            provider,
16974            SubscriberMode::Polling,
16975            SubscriberConfig::default(),
16976        );
16977        subscriber.chain_id = Some(1);
16978        subscriber.sources_dirty = false;
16979        let mut first_poll = true;
16980        let fetch = poll_fn(move |cx| {
16981            if first_poll {
16982                first_poll = false;
16983                cx.waker().wake_by_ref();
16984                std::task::Poll::Pending
16985            } else {
16986                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
16987            }
16988        });
16989
16990        let result = subscriber
16991            .drive_reconcile_fetch(fetch, &HashSet::new())
16992            .await
16993            .expect("an empty live topology must not be mistaken for termination");
16994        assert_eq!(result, "certified");
16995    }
16996
16997    #[tokio::test(flavor = "multi_thread")]
16998    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
16999    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
17000        use alloy_rpc_types_eth::{Block, Header};
17001
17002        let asserter = Asserter::new();
17003        let baseline = BlockRef {
17004            number: 100,
17005            hash: B256::repeat_byte(0x64),
17006            parent_hash: Some(B256::repeat_byte(0x63)),
17007            timestamp: Some(1_700_000_100),
17008        };
17009        let through = BlockRef {
17010            number: 101,
17011            hash: B256::repeat_byte(0x65),
17012            parent_hash: Some(baseline.hash),
17013            timestamp: Some(1_700_000_101),
17014        };
17015        let rpc_block = || -> Block {
17016            Block::empty(Header {
17017                hash: through.hash,
17018                inner: alloy_consensus::Header {
17019                    number: through.number,
17020                    parent_hash: through.parent_hash.unwrap(),
17021                    timestamp: through.timestamp.unwrap(),
17022                    ..Default::default()
17023                },
17024                total_difficulty: None,
17025                size: None,
17026            })
17027        };
17028        asserter.push_success(&Some(rpc_block()));
17029        asserter.push_success(&Vec::<Log>::new());
17030        asserter.push_success(&Some(rpc_block()));
17031        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
17032        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17033            provider,
17034            SubscriberMode::PubSub,
17035            SubscriberConfig::default(),
17036        );
17037        subscriber.chain_id = Some(1);
17038        let interest = ReactiveInterest::Logs(LogInterest {
17039            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
17040            local_matcher: None,
17041            route_key: None,
17042        });
17043        let epoch = subscriber
17044            .stage_interest_owner(
17045                HandlerId::new("reconnect-anchor"),
17046                std::slice::from_ref(&interest),
17047                SubscriberOwnerStart::PostBlock(baseline),
17048            )
17049            .unwrap();
17050        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
17051        let source = SubscriberStreamSource::PubSubLog {
17052            id: subscriber.log_source_id(&filter),
17053            filter: filter.clone(),
17054        };
17055        let mut streams = SubscriberStreams::new();
17056        streams.push(source, stream::pending().boxed());
17057        subscriber.state = AlloySubscriberState::Active(streams);
17058        subscriber.sources_dirty = false;
17059
17060        subscriber
17061            .reconcile_interest_owner(&epoch, through)
17062            .await
17063            .unwrap();
17064        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
17065        assert!(asserter.read_q().is_empty());
17066    }
17067
17068    #[tokio::test(flavor = "multi_thread")]
17069    #[cfg(feature = "reactive-polling")]
17070    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
17071        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17072        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17073            provider,
17074            SubscriberMode::Polling,
17075            SubscriberConfig::default(),
17076        );
17077        let interest = ReactiveInterest::Logs(LogInterest {
17078            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
17079            local_matcher: None,
17080            route_key: None,
17081        });
17082        let epoch = subscriber
17083            .stage_interest_owner(
17084                HandlerId::new("owner"),
17085                std::slice::from_ref(&interest),
17086                SubscriberOwnerStart::PostBlock(BlockRef {
17087                    number: 100,
17088                    hash: B256::repeat_byte(0x64),
17089                    parent_hash: None,
17090                    timestamp: None,
17091                }),
17092            )
17093            .unwrap();
17094        subscriber.sources_dirty = false;
17095
17096        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
17097        let event = SubscriberEvent::Log {
17098            source_id: 0,
17099            log: rpc_log(false),
17100        };
17101        let mut streams = SubscriberStreams::new();
17102        streams.push(
17103            SubscriberStreamSource::PollingLog { filter },
17104            stream::once(async move { event })
17105                .chain(stream::pending())
17106                .boxed(),
17107        );
17108        subscriber.state = AlloySubscriberState::Active(streams);
17109
17110        let targets = HashSet::from([epoch.clone()]);
17111        {
17112            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
17113            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
17114            futures::pin_mut!(drive);
17115            poll_fn(|cx| {
17116                assert!(drive.as_mut().poll(cx).is_pending());
17117                std::task::Poll::Ready(())
17118            })
17119            .await;
17120        }
17121
17122        assert_eq!(subscriber.pending_records.len(), 1);
17123        assert_eq!(
17124            subscriber.pending_records[0].scope,
17125            SubscriberInputScope::Canonical { owners: Vec::new() },
17126            "canonical delivery commits immediately at a cancellation-safe boundary"
17127        );
17128        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
17129
17130        subscriber
17131            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
17132            .await
17133            .unwrap();
17134        subscriber.promote_reconcile_owner_records(&targets);
17135        assert!(subscriber.pending_reconcile_owner_records.is_empty());
17136        assert_eq!(subscriber.pending_records.len(), 2);
17137        assert_eq!(
17138            subscriber.pending_records[0].scope,
17139            SubscriberInputScope::Canonical { owners: Vec::new() },
17140            "canonical delivery remains target-excluded"
17141        );
17142        assert_eq!(
17143            subscriber.pending_records[1].scope,
17144            SubscriberInputScope::OwnerOnly {
17145                owners: vec![epoch]
17146            },
17147            "retry commit appends hidden owner delivery after historical catch-up"
17148        );
17149    }
17150
17151    #[tokio::test(flavor = "multi_thread")]
17152    #[cfg(feature = "reactive-ws")]
17153    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
17154        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17155        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17156            provider,
17157            SubscriberMode::PubSub,
17158            SubscriberConfig {
17159                reconnect: SubscriberReconnectConfig {
17160                    initial_delay: Duration::from_secs(60),
17161                    ..SubscriberReconnectConfig::default()
17162                },
17163                ..SubscriberConfig::default()
17164            },
17165        );
17166        subscriber.chain_id = Some(1);
17167        let interest = ReactiveInterest::Logs(LogInterest {
17168            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
17169            local_matcher: None,
17170            route_key: None,
17171        });
17172        let epoch = subscriber
17173            .stage_interest_owner(
17174                HandlerId::new("owner"),
17175                std::slice::from_ref(&interest),
17176                SubscriberOwnerStart::PostBlock(BlockRef {
17177                    number: 7,
17178                    hash: B256::repeat_byte(0x07),
17179                    parent_hash: Some(B256::repeat_byte(0x06)),
17180                    timestamp: Some(1_700_000_007),
17181                }),
17182            )
17183            .unwrap();
17184        subscriber.sources_dirty = false;
17185        subscriber.stream_revision = 1;
17186        let entry = subscriber
17187            .owned_interests
17188            .iter_mut()
17189            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
17190            .unwrap();
17191        entry.progress = Some(SubscriberOwnerProgress {
17192            owner: epoch.clone(),
17193            through: entry.baseline.unwrap(),
17194        });
17195        entry.progress_stream_revision = Some(1);
17196
17197        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
17198        let source = SubscriberStreamSource::PubSubLog {
17199            id: subscriber.log_source_id(&filter),
17200            filter,
17201        };
17202        let mut streams = SubscriberStreams::new();
17203        streams.push(
17204            source.clone(),
17205            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
17206        );
17207        subscriber.state = AlloySubscriberState::Active(streams);
17208        let prior_revision = subscriber.stream_revision;
17209
17210        let mut first_poll = true;
17211        let control = poll_fn(move |cx| {
17212            if first_poll {
17213                first_poll = false;
17214                cx.waker().wake_by_ref();
17215                std::task::Poll::Pending
17216            } else {
17217                std::task::Poll::Ready("stop")
17218            }
17219        });
17220        futures::pin_mut!(control);
17221        let outcome = subscriber
17222            .next_scoped_batch_or(control.as_mut())
17223            .await
17224            .unwrap();
17225
17226        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
17227        assert!(subscriber.sources_dirty);
17228        assert!(subscriber.stream_revision > prior_revision);
17229        assert!(
17230            !subscriber.activate_interest_owner(&epoch),
17231            "progress certified against the terminated stream revision is stale"
17232        );
17233    }
17234
17235    #[tokio::test]
17236    #[cfg(feature = "reactive-ws")]
17237    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
17238        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17239        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17240            provider,
17241            SubscriberMode::PubSub,
17242            SubscriberConfig::default(),
17243        );
17244        subscriber.chain_id = Some(1);
17245        subscriber
17246            .register_interests(&[
17247                ReactiveInterest::Logs(LogInterest {
17248                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
17249                    local_matcher: None,
17250                    route_key: None,
17251                }),
17252                ReactiveInterest::Logs(LogInterest {
17253                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
17254                    local_matcher: None,
17255                    route_key: None,
17256                }),
17257                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
17258            ])
17259            .await
17260            .expect("register base interests");
17261
17262        // The two default-block-option log filters merge into one address
17263        // superset (existing consolidation behavior), so there is one log source
17264        // — assigned id 0, before the pending-hash source.
17265        let sources = subscriber.stream_sources().expect("stream sources");
17266        assert_eq!(sources.len(), 2);
17267        assert!(matches!(
17268            &sources[0],
17269            SubscriberStreamSource::PubSubLog { id: 0, .. }
17270        ));
17271        assert!(matches!(
17272            sources[1],
17273            SubscriberStreamSource::PubSubPendingHashes
17274        ));
17275
17276        // Ids are stable across repeated source construction.
17277        let again = subscriber.stream_sources().expect("stream sources again");
17278        assert!(again[0].same_key(&sources[0]));
17279    }
17280
17281    #[tokio::test(flavor = "multi_thread")]
17282    #[cfg(feature = "reactive-ws")]
17283    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
17284        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17285        let mut subscriber = AlloySubscriber::new(
17286            provider,
17287            SubscriberMode::PubSub,
17288            SubscriberConfig {
17289                reconnect: SubscriberReconnectConfig {
17290                    initial_delay: Duration::ZERO,
17291                    retry_delay: Duration::ZERO,
17292                    max_delay: Duration::ZERO,
17293                    max_attempts: Some(1),
17294                    ..SubscriberReconnectConfig::default()
17295                },
17296                ..SubscriberConfig::default()
17297            },
17298        );
17299        subscriber.chain_id = Some(1);
17300        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
17301            PendingTxInterest::default(),
17302        )];
17303
17304        let mut streams = SubscriberStreams::new();
17305        let source = SubscriberStreamSource::PubSubPendingHashes;
17306        streams.push(
17307            source,
17308            stream::once(async {
17309                SubscriberEvent::<Ethereum>::StreamTerminated(
17310                    SubscriberStreamSource::PubSubPendingHashes,
17311                )
17312            })
17313            .boxed(),
17314        );
17315        subscriber.state = AlloySubscriberState::Active(streams);
17316
17317        let result = subscriber.next_batch().await;
17318        assert!(
17319            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
17320            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
17321        );
17322    }
17323
17324    #[test]
17325    fn backfilled_logs_skip_recent_subscription_duplicates() {
17326        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17327        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17328            provider,
17329            SubscriberMode::PubSub,
17330            SubscriberConfig::default(),
17331        );
17332        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
17333            provider_filter: Filter::new()
17334                .address(Address::repeat_byte(0x42))
17335                .event_signature(B256::repeat_byte(0x01)),
17336            local_matcher: None,
17337            route_key: None,
17338        })];
17339
17340        let log = rpc_log(false);
17341        subscriber.enqueue_event(SubscriberEvent::Log {
17342            source_id: 0,
17343            log: log.clone(),
17344        });
17345        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
17346            source_id: 0,
17347            logs: vec![log],
17348        });
17349
17350        assert_eq!(subscriber.pending_records.len(), 1);
17351        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
17352        assert_eq!(
17353            subscriber.pending_records[0].context.source,
17354            InputSource::Subscription
17355        );
17356    }
17357
17358    #[test]
17359    fn backfilled_logs_surface_with_backfill_source() {
17360        // A backfilled log with no prior subscription duplicate is delivered as
17361        // an `InputSource::Backfill` record (the positive side of the dedup test,
17362        // pinning the README's "marking recovered records as InputSource::Backfill"
17363        // claim — the only place that source is produced).
17364        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17365        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17366            provider,
17367            SubscriberMode::PubSub,
17368            SubscriberConfig::default(),
17369        );
17370        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
17371            provider_filter: Filter::new()
17372                .address(Address::repeat_byte(0x42))
17373                .event_signature(B256::repeat_byte(0x01)),
17374            local_matcher: None,
17375            route_key: None,
17376        })];
17377
17378        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
17379            source_id: 0,
17380            logs: vec![rpc_log(false)],
17381        });
17382
17383        assert_eq!(subscriber.pending_records.len(), 1);
17384        assert_eq!(
17385            subscriber.pending_records[0].context.source,
17386            InputSource::Backfill
17387        );
17388        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
17389    }
17390
17391    #[test]
17392    #[cfg(feature = "reactive-ws")]
17393    fn owner_removal_preserves_delivery_and_dedupe_state() {
17394        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17395        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17396            provider,
17397            SubscriberMode::PubSub,
17398            SubscriberConfig::default(),
17399        );
17400        subscriber
17401            .add_interest_owner(
17402                HandlerId::new("pool-a"),
17403                &[ReactiveInterest::Logs(LogInterest {
17404                    provider_filter: Filter::new()
17405                        .address(Address::repeat_byte(0x42))
17406                        .event_signature(B256::repeat_byte(0x01)),
17407                    local_matcher: None,
17408                    route_key: None,
17409                })],
17410            )
17411            .expect("register pool-a owner");
17412        subscriber
17413            .add_interest_owner(
17414                HandlerId::new("pool-b"),
17415                &[ReactiveInterest::Logs(LogInterest {
17416                    provider_filter: Filter::new()
17417                        .address(Address::repeat_byte(0x24))
17418                        .event_signature(B256::repeat_byte(0x02)),
17419                    local_matcher: None,
17420                    route_key: None,
17421                })],
17422            )
17423            .expect("register pool-b owner");
17424
17425        // Allocate source ids the way live stream setup would (pool-a -> id 0),
17426        // so the injected delivery anchor hangs off a referenced filter.
17427        let sources = subscriber.stream_sources().expect("stream sources");
17428        subscriber.enqueue_event(SubscriberEvent::Log {
17429            source_id: 0,
17430            log: rpc_log(false),
17431        });
17432        let mut streams = SubscriberStreams::new();
17433        streams.push(
17434            sources[0].clone(),
17435            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
17436        );
17437        subscriber.state = AlloySubscriberState::Active(streams);
17438        assert_eq!(subscriber.pending_records.len(), 1);
17439        assert_eq!(subscriber.recent_input_refs.len(), 1);
17440        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
17441
17442        let removed = subscriber
17443            .remove_interest_owner(&HandlerId::new("pool-b"))
17444            .expect("pool-b should be removed");
17445
17446        assert_eq!(removed.len(), 1);
17447        assert_eq!(subscriber.pending_records.len(), 1);
17448        assert_eq!(subscriber.recent_input_refs.len(), 1);
17449        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
17450        assert!(
17451            subscriber
17452                .owner_interests(&HandlerId::new("pool-a"))
17453                .is_some()
17454        );
17455        assert!(
17456            subscriber
17457                .owner_interests(&HandlerId::new("pool-b"))
17458                .is_none()
17459        );
17460        assert_eq!(subscriber.registered_interests().len(), 1);
17461    }
17462
17463    #[test]
17464    #[cfg(feature = "reactive-ws")]
17465    fn owner_log_sources_fan_in_across_owners() {
17466        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17467        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17468            provider,
17469            SubscriberMode::PubSub,
17470            SubscriberConfig::default(),
17471        );
17472        subscriber
17473            .add_interest_owner(
17474                HandlerId::new("pool-a"),
17475                &[ReactiveInterest::Logs(LogInterest {
17476                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
17477                    local_matcher: None,
17478                    route_key: None,
17479                })],
17480            )
17481            .expect("register pool-a owner");
17482
17483        let initial_sources = subscriber.stream_sources().expect("initial sources");
17484        assert_eq!(initial_sources.len(), 1);
17485        let pool_a_source = initial_sources[0].clone();
17486        assert!(matches!(
17487            &pool_a_source,
17488            SubscriberStreamSource::PubSubLog { id: 0, .. }
17489        ));
17490
17491        subscriber
17492            .add_interest_owner(
17493                HandlerId::new("pool-b"),
17494                &[ReactiveInterest::Logs(LogInterest {
17495                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
17496                    local_matcher: None,
17497                    route_key: None,
17498                })],
17499            )
17500            .expect("register pool-b owner");
17501
17502        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
17503        assert_eq!(
17504            expanded_sources.len(),
17505            1,
17506            "compatible owner filters should share one provider subscription"
17507        );
17508        assert!(
17509            !expanded_sources[0].same_key(&pool_a_source),
17510            "the provider-facing superset changes while owner routing remains exact"
17511        );
17512
17513        subscriber
17514            .remove_interest_owner(&HandlerId::new("pool-b"))
17515            .expect("pool-b should be removed");
17516        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
17517        assert_eq!(trimmed_sources.len(), 1);
17518        assert!(trimmed_sources[0].same_key(&pool_a_source));
17519    }
17520
17521    #[test]
17522    #[cfg(feature = "reactive-ws")]
17523    fn provider_log_fan_in_respects_address_ceiling() {
17524        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17525        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17526            provider,
17527            SubscriberMode::PubSub,
17528            SubscriberConfig {
17529                max_log_addresses_per_subscription: 2,
17530                ..SubscriberConfig::default()
17531            },
17532        );
17533        for index in 0..5 {
17534            subscriber
17535                .add_interest_owner(
17536                    HandlerId::new(format!("pool-{index}")),
17537                    &[log_interest_for(index + 1)],
17538                )
17539                .expect("register pool owner");
17540        }
17541
17542        let sources = subscriber.stream_sources().expect("stream sources");
17543        assert_eq!(sources.len(), 3);
17544        let mut address_counts: Vec<_> = sources
17545            .iter()
17546            .map(|source| match source {
17547                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
17548                _ => panic!("expected log source"),
17549            })
17550            .collect();
17551        address_counts.sort_unstable();
17552        assert_eq!(address_counts, vec![1, 2, 2]);
17553    }
17554
17555    #[tokio::test(flavor = "multi_thread")]
17556    #[cfg(feature = "reactive-ws")]
17557    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
17558        let asserter = Asserter::new();
17559        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
17560        asserter.push_success(&vec![rpc_log(false)]);
17561        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
17562        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17563        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17564            provider,
17565            SubscriberMode::PubSub,
17566            SubscriberConfig::default(),
17567        );
17568        subscriber
17569            .add_interest_owner_with_backfill(
17570                HandlerId::new("pool-a"),
17571                &[ReactiveInterest::Logs(LogInterest {
17572                    provider_filter: Filter::new()
17573                        .address(Address::repeat_byte(0x42))
17574                        .event_signature(B256::repeat_byte(0x01)),
17575                    local_matcher: None,
17576                    route_key: None,
17577                })],
17578                SubscriberBackfill::range(1, 7),
17579            )
17580            .expect("register pool-a with backfill");
17581
17582        subscriber
17583            .drain_pending_backfills()
17584            .await
17585            .expect("owner backfill should drain");
17586
17587        assert_eq!(subscriber.pending_records.len(), 1);
17588        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
17589    }
17590
17591    #[tokio::test(flavor = "multi_thread")]
17592    async fn subscriber_streams_poll_ready_sources_round_robin() {
17593        let first_hash = B256::repeat_byte(0x01);
17594        let second_hash = B256::repeat_byte(0x02);
17595        let mut streams = SubscriberStreams::new();
17596        streams.push(
17597            SubscriberStreamSource::PubSubPendingHashes,
17598            stream::iter([
17599                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
17600                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
17601            ])
17602            .boxed(),
17603        );
17604        streams.push(
17605            SubscriberStreamSource::PubSubBlockHeaders,
17606            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
17607                .boxed(),
17608        );
17609
17610        assert!(matches!(
17611            streams.next().await,
17612            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
17613        ));
17614        assert!(matches!(
17615            streams.next().await,
17616            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
17617        ));
17618    }
17619
17620    #[tokio::test(flavor = "multi_thread")]
17621    #[cfg(feature = "reactive-ws")]
17622    async fn owner_updates_ensure_streams_without_full_reset() {
17623        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17624        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17625            provider,
17626            SubscriberMode::PubSub,
17627            SubscriberConfig::default(),
17628        );
17629        subscriber.chain_id = Some(1);
17630        subscriber
17631            .register_interests(&[ReactiveInterest::PendingTransactions(
17632                PendingTxInterest::default(),
17633            )])
17634            .await
17635            .expect("register base pending interest");
17636        subscriber
17637            .add_interest_owner(
17638                HandlerId::new("headers"),
17639                &[ReactiveInterest::Blocks(BlockInterest::default())],
17640            )
17641            .expect("register header owner");
17642
17643        let mut streams = SubscriberStreams::new();
17644        streams.push(
17645            SubscriberStreamSource::PubSubPendingHashes,
17646            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
17647        );
17648        streams.push(
17649            SubscriberStreamSource::PubSubBlockHeaders,
17650            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
17651        );
17652        subscriber.state = AlloySubscriberState::Active(streams);
17653
17654        subscriber
17655            .remove_interest_owner(&HandlerId::new("headers"))
17656            .expect("header owner should be removed");
17657        assert!(matches!(
17658            &subscriber.state,
17659            AlloySubscriberState::Active(streams) if streams.len() == 2
17660        ));
17661
17662        subscriber
17663            .ensure_streams()
17664            .await
17665            .expect("pure removal reconciliation should not touch provider");
17666
17667        assert!(matches!(
17668            &subscriber.state,
17669            AlloySubscriberState::Active(streams)
17670                if streams.len() == 1
17671                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
17672                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
17673        ));
17674
17675        subscriber
17676            .add_interest_owner(
17677                HandlerId::new("headers"),
17678                &[ReactiveInterest::Blocks(BlockInterest::default())],
17679            )
17680            .expect("re-add header owner");
17681        assert!(matches!(
17682            &subscriber.state,
17683            AlloySubscriberState::Active(streams) if streams.len() == 1
17684        ));
17685    }
17686
17687    #[tokio::test(flavor = "multi_thread")]
17688    #[cfg(feature = "reactive-polling")]
17689    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
17690        let asserter = Asserter::new();
17691        asserter.push_success(&U256::from(1));
17692        asserter.push_failure_msg("second filter connection failed");
17693        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
17694        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17695            provider,
17696            SubscriberMode::Polling,
17697            SubscriberConfig {
17698                max_log_addresses_per_subscription: 1,
17699                ..SubscriberConfig::default()
17700            },
17701        );
17702        subscriber.chain_id = Some(1);
17703        subscriber
17704            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
17705            .await
17706            .expect("register two independently connected filters");
17707
17708        let error = subscriber
17709            .ensure_streams()
17710            .await
17711            .expect_err("second provider connection is forced to fail");
17712        assert!(matches!(error, SubscriberError::Provider(_)));
17713        assert!(subscriber.sources_dirty);
17714        let retained_streams = match &subscriber.state {
17715            AlloySubscriberState::Active(streams) => Some(streams.len()),
17716            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
17717        };
17718        assert_eq!(
17719            retained_streams,
17720            Some(1),
17721            "first connection must survive later error {error:?}; revision {}",
17722            subscriber.stream_revision
17723        );
17724
17725        asserter.push_success(&U256::from(2));
17726        subscriber
17727            .ensure_streams()
17728            .await
17729            .expect("retry connects only the missing source");
17730        assert!(!subscriber.sources_dirty);
17731        assert!(matches!(
17732            &subscriber.state,
17733            AlloySubscriberState::Active(streams) if streams.len() == 2
17734        ));
17735        assert!(asserter.read_q().is_empty());
17736    }
17737
17738    #[tokio::test(flavor = "multi_thread")]
17739    #[cfg(feature = "reactive-ws")]
17740    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
17741        let asserter = Asserter::new();
17742        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
17743        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17744            provider,
17745            SubscriberMode::PubSub,
17746            SubscriberConfig::default(),
17747        );
17748        subscriber.chain_id = Some(1);
17749        subscriber
17750            .register_interests(&[log_interest_for(0x43)])
17751            .await
17752            .expect("register log source");
17753        let source = subscriber
17754            .stream_sources()
17755            .expect("one desired source")
17756            .pop()
17757            .expect("log source");
17758        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
17759            panic!("expected pubsub log source")
17760        };
17761        subscriber.last_seen_log_blocks.insert(id, 6);
17762
17763        {
17764            let source = SubscriberStreamSource::PubSubLog {
17765                id,
17766                filter: subscriber
17767                    .log_stream_filters()
17768                    .pop()
17769                    .expect("provider filter"),
17770            };
17771            let interrupted = async {
17772                subscriber.install_source_stream(
17773                    source.clone(),
17774                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
17775                );
17776                subscriber.queue_source_backfill(source);
17777                subscriber.sources_dirty = true;
17778                futures::future::pending::<()>().await;
17779            };
17780            futures::pin_mut!(interrupted);
17781            poll_fn(|cx| {
17782                assert!(interrupted.as_mut().poll(cx).is_pending());
17783                std::task::Poll::Ready(())
17784            })
17785            .await;
17786        }
17787
17788        assert_eq!(subscriber.pending_source_backfills.len(), 1);
17789        assert!(matches!(
17790            &subscriber.state,
17791            AlloySubscriberState::Active(streams) if streams.len() == 1
17792        ));
17793
17794        asserter.push_success(&7u64);
17795        asserter.push_success(&Vec::<Log>::new());
17796        subscriber
17797            .ensure_streams()
17798            .await
17799            .expect("retry completes only the pending historical window");
17800
17801        assert!(subscriber.pending_source_backfills.is_empty());
17802        assert!(!subscriber.sources_dirty);
17803        assert!(matches!(
17804            &subscriber.state,
17805            AlloySubscriberState::Active(streams) if streams.len() == 1
17806        ));
17807        assert!(asserter.read_q().is_empty());
17808    }
17809
17810    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
17811    #[cfg(feature = "reactive-ws")]
17812    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
17813        ReactiveInterest::Logs(LogInterest {
17814            provider_filter: Filter::new()
17815                .address(Address::repeat_byte(0x42))
17816                .event_signature(B256::repeat_byte(0x01)),
17817            local_matcher: None,
17818            route_key: None,
17819        })
17820    }
17821
17822    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
17823    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
17824        ReactiveInterest::Logs(LogInterest {
17825            provider_filter: Filter::new().address(Address::repeat_byte(address)),
17826            local_matcher: None,
17827            route_key: None,
17828        })
17829    }
17830
17831    // B1: a transient provider error must not consume the queued backfill — the
17832    // missed window has to survive for the next poll to retry.
17833    #[tokio::test(flavor = "multi_thread")]
17834    #[cfg(feature = "reactive-ws")]
17835    async fn drain_backfill_retains_queue_entry_on_provider_error() {
17836        let asserter = Asserter::new();
17837        asserter.push_failure_msg("rate limited");
17838        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
17839        asserter.push_success(&vec![rpc_log(false)]);
17840        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
17841        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17842        let mut subscriber = AlloySubscriber::new(
17843            provider,
17844            SubscriberMode::PubSub,
17845            SubscriberConfig::default(),
17846        );
17847        subscriber
17848            .add_interest_owner_with_backfill(
17849                HandlerId::new("pool"),
17850                &[log_interest_matching_rpc_log()],
17851                SubscriberBackfill::range(1, 7),
17852            )
17853            .expect("register owner with backfill");
17854        assert_eq!(subscriber.pending_backfills.len(), 1);
17855
17856        let first = subscriber.drain_pending_backfills().await;
17857        assert!(first.is_err(), "provider failure should surface");
17858        assert_eq!(
17859            subscriber.pending_backfills.len(),
17860            1,
17861            "failed fetch must leave the backfill queued for retry"
17862        );
17863        assert!(subscriber.pending_records.is_empty());
17864
17865        subscriber
17866            .drain_pending_backfills()
17867            .await
17868            .expect("retry should succeed");
17869        assert!(subscriber.pending_backfills.is_empty());
17870        assert_eq!(subscriber.pending_records.len(), 1);
17871    }
17872
17873    // B3: a zero-log backfill window still advances the delivery anchor to its
17874    // upper bound, so a later reconnect catches up from the right block.
17875    #[tokio::test(flavor = "multi_thread")]
17876    #[cfg(feature = "reactive-ws")]
17877    async fn drain_backfill_seeds_anchor_on_empty_window() {
17878        let asserter = Asserter::new();
17879        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
17880        asserter.push_success(&Vec::<Log>::new());
17881        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
17882        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17883        let mut subscriber = AlloySubscriber::new(
17884            provider,
17885            SubscriberMode::PubSub,
17886            SubscriberConfig::default(),
17887        );
17888        subscriber
17889            .add_interest_owner_with_backfill(
17890                HandlerId::new("pool"),
17891                &[log_interest_matching_rpc_log()],
17892                SubscriberBackfill::range(1, 42),
17893            )
17894            .expect("register owner with backfill");
17895
17896        subscriber
17897            .drain_pending_backfills()
17898            .await
17899            .expect("empty backfill should drain");
17900
17901        assert!(subscriber.pending_records.is_empty());
17902        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
17903            .pop()
17904            .unwrap();
17905        assert_eq!(
17906            subscriber.log_anchor(&filter),
17907            Some(42),
17908            "empty window must still seed the anchor at its upper bound"
17909        );
17910    }
17911
17912    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
17913    // the provider head and seeds the anchor there.
17914    #[tokio::test(flavor = "multi_thread")]
17915    #[cfg(feature = "reactive-ws")]
17916    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
17917        let asserter = Asserter::new();
17918        asserter.push_success(&100u64); // get_block_number
17919        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
17920        asserter.push_success(&Vec::<Log>::new()); // get_logs
17921        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
17922        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17923        let mut subscriber = AlloySubscriber::new(
17924            provider,
17925            SubscriberMode::PubSub,
17926            SubscriberConfig::default(),
17927        );
17928        subscriber
17929            .add_interest_owner_with_backfill(
17930                HandlerId::new("pool"),
17931                &[log_interest_matching_rpc_log()],
17932                SubscriberBackfill::from_block(10),
17933            )
17934            .expect("register owner with open-ended backfill");
17935
17936        subscriber
17937            .drain_pending_backfills()
17938            .await
17939            .expect("open-ended backfill should drain");
17940
17941        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
17942            .pop()
17943            .unwrap();
17944        assert_eq!(subscriber.log_anchor(&filter), Some(100));
17945    }
17946
17947    // B2: two owners requesting the same filter shape share exactly one live
17948    // source (and thus one anchor), rather than double-subscribing.
17949    #[test]
17950    #[cfg(feature = "reactive-ws")]
17951    fn duplicate_filters_across_owners_map_to_single_source() {
17952        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17953        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17954            provider,
17955            SubscriberMode::PubSub,
17956            SubscriberConfig::default(),
17957        );
17958        subscriber
17959            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
17960            .expect("register pool-a");
17961        subscriber
17962            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
17963            .expect("register pool-b with identical filter");
17964
17965        assert_eq!(
17966            subscriber.log_stream_filters().len(),
17967            1,
17968            "identical filters across owners must collapse to one"
17969        );
17970        let sources = subscriber.stream_sources().expect("stream sources");
17971        assert_eq!(sources.len(), 1);
17972    }
17973
17974    // B4: removing an owner retires the source-id and anchor bookkeeping for
17975    // filters no other owner references, so long-lived churn cannot leak.
17976    #[test]
17977    #[cfg(feature = "reactive-ws")]
17978    fn owner_removal_prunes_source_ids_and_anchors() {
17979        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17980        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17981            provider,
17982            SubscriberMode::PubSub,
17983            SubscriberConfig::default(),
17984        );
17985        subscriber
17986            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
17987            .expect("register pool-a");
17988        subscriber
17989            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
17990            .expect("register pool-b");
17991
17992        // Allocate ids and simulate delivery anchors on both.
17993        let _ = subscriber.stream_sources().expect("stream sources");
17994        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
17995        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
17996        let id_a = subscriber.log_source_id(&filter_a);
17997        let id_b = subscriber.log_source_id(&filter_b);
17998        subscriber.last_seen_log_blocks.insert(id_a, 10);
17999        subscriber.last_seen_log_blocks.insert(id_b, 20);
18000        assert_eq!(
18001            subscriber.log_source_ids.len(),
18002            3,
18003            "one provider fan-in id plus two explicitly seeded logical ids"
18004        );
18005
18006        subscriber
18007            .remove_interest_owner(&HandlerId::new("pool-b"))
18008            .expect("remove pool-b");
18009
18010        assert_eq!(
18011            subscriber.log_source_ids.len(),
18012            1,
18013            "pool-b's filter id should be retired"
18014        );
18015        assert!(subscriber.log_source_ids.contains_key(&filter_a));
18016        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
18017        assert_eq!(
18018            subscriber.last_seen_log_blocks.get(&id_b),
18019            None,
18020            "pool-b's anchor should be pruned"
18021        );
18022    }
18023
18024    // D1: growing an owner's filter set (a new pool on an existing adapter)
18025    // changes the merged filter shape; the new shape must inherit the old
18026    // anchor via an automatic continuity backfill, or logs between the last
18027    // delivery and the new subscription are silently lost.
18028    #[test]
18029    #[cfg(feature = "reactive-ws")]
18030    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
18031        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18032        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18033            provider,
18034            SubscriberMode::PubSub,
18035            SubscriberConfig::default(),
18036        );
18037        subscriber
18038            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
18039            .expect("register amm with pool A");
18040
18041        // Simulate the owner's single merged filter having delivered up to
18042        // block 50.
18043        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
18044        let id_a = subscriber.log_source_id(&filter_a);
18045        subscriber.last_seen_log_blocks.insert(id_a, 50);
18046
18047        // Grow the owner to also watch pool B (same block option -> merges into
18048        // one {A,B} filter, a new shape).
18049        subscriber
18050            .add_interest_owner(
18051                HandlerId::new("amm"),
18052                &[log_interest_for(0xaa), log_interest_for(0xbb)],
18053            )
18054            .expect("grow amm to pools A+B");
18055
18056        assert_eq!(
18057            subscriber.pending_backfills.len(),
18058            1,
18059            "the changed merged filter should queue exactly one continuity backfill"
18060        );
18061        let queued = &subscriber.pending_backfills[0];
18062        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
18063        assert_eq!(queued.backfill.start_block(), 50);
18064        assert_eq!(
18065            queued.backfill.end_block(),
18066            None,
18067            "continuity backfill runs open-ended to the current head"
18068        );
18069    }
18070
18071    // D1 negative: replacing an owner's interests with the identical shape must
18072    // NOT re-fetch — the filter kept its anchor and its live stream.
18073    #[test]
18074    #[cfg(feature = "reactive-ws")]
18075    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
18076        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18077        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18078            provider,
18079            SubscriberMode::PubSub,
18080            SubscriberConfig::default(),
18081        );
18082        subscriber
18083            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
18084            .expect("register amm");
18085        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
18086        let id_a = subscriber.log_source_id(&filter_a);
18087        subscriber.last_seen_log_blocks.insert(id_a, 50);
18088
18089        subscriber
18090            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
18091            .expect("re-register identical interests");
18092
18093        assert!(
18094            subscriber.pending_backfills.is_empty(),
18095            "an unchanged filter shape must not queue continuity backfill"
18096        );
18097    }
18098
18099    // D5 interaction: an explicit open-ended backfill starting at or below the
18100    // owner's prior anchor already covers the continuity window, so no extra
18101    // continuity backfill is queued (no redundant double fetch).
18102    #[test]
18103    #[cfg(feature = "reactive-ws")]
18104    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
18105        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18106        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18107            provider,
18108            SubscriberMode::PubSub,
18109            SubscriberConfig::default(),
18110        );
18111        subscriber
18112            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
18113            .expect("register amm");
18114        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
18115        let id_a = subscriber.log_source_id(&filter_a);
18116        subscriber.last_seen_log_blocks.insert(id_a, 50);
18117
18118        // Grow with an explicit deep backfill from block 10 (< anchor 50).
18119        subscriber
18120            .add_interest_owner_with_backfill(
18121                HandlerId::new("amm"),
18122                &[log_interest_for(0xaa), log_interest_for(0xbb)],
18123                SubscriberBackfill::from_block(10),
18124            )
18125            .expect("grow amm with explicit deep backfill");
18126
18127        assert_eq!(
18128            subscriber.pending_backfills.len(),
18129            1,
18130            "only the explicit backfill should be queued; continuity is subsumed"
18131        );
18132        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
18133    }
18134
18135    // The dirty flag gates reconciliation: when nothing changed since the last
18136    // reconcile, `ensure_streams` must not touch the provider or the state.
18137    #[tokio::test(flavor = "multi_thread")]
18138    #[cfg(feature = "reactive-ws")]
18139    async fn ensure_streams_is_noop_when_not_dirty() {
18140        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18141        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18142            provider,
18143            SubscriberMode::PubSub,
18144            SubscriberConfig::default(),
18145        );
18146        // An interest that WOULD require a new block-header source...
18147        subscriber
18148            .add_interest_owner(
18149                HandlerId::new("headers"),
18150                &[ReactiveInterest::Blocks(BlockInterest::default())],
18151            )
18152            .expect("register header owner");
18153        // ...but we mark bookkeeping clean and start from Empty.
18154        subscriber.state = AlloySubscriberState::Empty;
18155        subscriber.sources_dirty = false;
18156
18157        subscriber
18158            .ensure_streams()
18159            .await
18160            .expect("clean reconcile must be a no-op");
18161
18162        assert!(
18163            matches!(subscriber.state, AlloySubscriberState::Empty),
18164            "not-dirty ensure_streams must not connect new sources"
18165        );
18166    }
18167}
18168
18169fn resolve_subscriber_transport(
18170    mode: SubscriberMode,
18171) -> Result<SubscriberTransport, SubscriberError> {
18172    match mode {
18173        SubscriberMode::PubSub => {
18174            #[cfg(feature = "reactive-ws")]
18175            {
18176                Ok(SubscriberTransport::PubSub)
18177            }
18178            #[cfg(not(feature = "reactive-ws"))]
18179            {
18180                Err(SubscriberError::Unsupported(
18181                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
18182                ))
18183            }
18184        }
18185        SubscriberMode::Polling => {
18186            #[cfg(feature = "reactive-polling")]
18187            {
18188                Ok(SubscriberTransport::Polling)
18189            }
18190            #[cfg(not(feature = "reactive-polling"))]
18191            {
18192                Err(SubscriberError::Unsupported(
18193                    "AlloySubscriber polling mode requires the reactive-polling feature",
18194                ))
18195            }
18196        }
18197        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
18198    }
18199}
18200
18201fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
18202    #[cfg(feature = "reactive-ws")]
18203    {
18204        Ok(SubscriberTransport::PubSub)
18205    }
18206
18207    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
18208    {
18209        Ok(SubscriberTransport::Polling)
18210    }
18211
18212    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
18213    {
18214        Err(SubscriberError::Unsupported(
18215            "AlloySubscriber requires either reactive-ws or reactive-polling",
18216        ))
18217    }
18218}
18219
18220fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
18221    if config.preconfirmations != PreconfirmationMode::Disabled
18222        && config.flashblock_poll_interval.is_zero()
18223    {
18224        return Err(SubscriberError::InvalidConfig(
18225            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
18226        ));
18227    }
18228    if config.max_batch_size == 0 {
18229        return Err(SubscriberError::InvalidConfig(
18230            "SubscriberConfig::max_batch_size must be greater than zero",
18231        ));
18232    }
18233    if config.max_log_addresses_per_subscription == 0 {
18234        return Err(SubscriberError::InvalidConfig(
18235            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
18236        ));
18237    }
18238    if config.max_pending_records == 0 {
18239        return Err(SubscriberError::InvalidConfig(
18240            "SubscriberConfig::max_pending_records must be greater than zero",
18241        ));
18242    }
18243    if config.max_pending_backfills == 0 {
18244        return Err(SubscriberError::InvalidConfig(
18245            "SubscriberConfig::max_pending_backfills must be greater than zero",
18246        ));
18247    }
18248    if config.max_backfill_log_bytes == 0 {
18249        return Err(SubscriberError::InvalidConfig(
18250            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
18251        ));
18252    }
18253    if config.max_reconcile_requests_in_flight == 0 {
18254        return Err(SubscriberError::InvalidConfig(
18255            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
18256        ));
18257    }
18258    if config.reconnect.enabled {
18259        if config.reconnect.retry_delay > config.reconnect.max_delay {
18260            return Err(SubscriberError::InvalidConfig(
18261                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
18262            ));
18263        }
18264        if matches!(config.reconnect.max_attempts, Some(0)) {
18265            return Err(SubscriberError::InvalidConfig(
18266                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
18267            ));
18268        }
18269    }
18270    Ok(())
18271}
18272
18273fn validate_supported_interests<N: Network>(
18274    mode: SubscriberMode,
18275    config: &SubscriberConfig,
18276    interests: &[ReactiveInterest<N>],
18277) -> Result<(), SubscriberError> {
18278    let transport = resolve_subscriber_transport(mode)?;
18279
18280    for interest in interests {
18281        match interest {
18282            ReactiveInterest::Logs(_) => {}
18283            ReactiveInterest::PendingTransactions(interest)
18284                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
18285            ReactiveInterest::PendingTransactions(_) => {
18286                return Err(SubscriberError::Unsupported(
18287                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
18288                ));
18289            }
18290            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
18291                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
18292                (_, BlockInterestMode::FullBlock) => {
18293                    return Err(SubscriberError::Unsupported(
18294                        "AlloySubscriber full block streams are not implemented in this transport slice",
18295                    ));
18296                }
18297                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
18298                    return Err(SubscriberError::Unsupported(
18299                        "AlloySubscriber polling block streams are not implemented in this transport slice",
18300                    ));
18301                }
18302            },
18303        }
18304    }
18305
18306    Ok(())
18307}
18308
18309fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
18310    let mut filters = Vec::new();
18311    for interest in interests {
18312        if let ReactiveInterest::Logs(interest) = interest {
18313            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
18314        }
18315    }
18316    filters
18317}
18318
18319fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
18320    interests.iter().any(|interest| {
18321        matches!(
18322            interest,
18323            ReactiveInterest::Blocks(BlockInterest {
18324                mode: BlockInterestMode::Header,
18325            })
18326        )
18327    })
18328}
18329
18330fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
18331    interests.iter().any(|interest| {
18332        matches!(
18333            interest,
18334            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
18335        )
18336    })
18337}
18338
18339fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
18340    interests.iter().any(|interest| {
18341        matches!(
18342            interest,
18343            ReactiveInterest::Logs(interest) if interest.matches(log)
18344        )
18345    })
18346}
18347
18348fn validate_owner_backfill_logs(
18349    logs: &[Log],
18350    from_block: u64,
18351    through: &BlockRef,
18352) -> Result<(), SubscriberOwnerError> {
18353    for log in logs {
18354        if log.removed {
18355            return Err(SubscriberOwnerError::InvalidBackfillLog(
18356                "removed log in canonical catch-up",
18357            ));
18358        }
18359        let number = log
18360            .block_number
18361            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
18362                "log missing block number",
18363            ))?;
18364        let hash = log
18365            .block_hash
18366            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
18367                "log missing block hash",
18368            ))?;
18369        log.transaction_hash
18370            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
18371                "log missing transaction hash",
18372            ))?;
18373        log.transaction_index
18374            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
18375                "log missing transaction index",
18376            ))?;
18377        log.log_index
18378            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
18379                "log missing log index",
18380            ))?;
18381        if number < from_block || number > through.number {
18382            return Err(SubscriberOwnerError::InvalidBackfillLog(
18383                "log outside requested block range",
18384            ));
18385        }
18386        if number == through.number && hash != through.hash {
18387            return Err(SubscriberOwnerError::InvalidBackfillLog(
18388                "target-block log hash mismatch",
18389            ));
18390        }
18391    }
18392    Ok(())
18393}
18394
18395fn validate_backfill_resource_limits(
18396    logs: &[Log],
18397    max_logs: usize,
18398    max_log_bytes: usize,
18399) -> Result<usize, SubscriberError> {
18400    if logs.len() > max_logs {
18401        return Err(SubscriberError::ResourceExhausted(format!(
18402            "historical response returned {} logs, above the configured limit of {max_logs}",
18403            logs.len()
18404        )));
18405    }
18406    let bytes = logs.iter().fold(0usize, |total, log| {
18407        // Include fixed address/block/transaction/index fields in addition to
18408        // the variable topic and data payload. This is deliberately a stable
18409        // conservative accounting unit rather than Rust heap-layout size.
18410        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
18411        total
18412            .saturating_add(fixed)
18413            .saturating_add(log.topics().len().saturating_mul(32))
18414            .saturating_add(log.inner.data.data.len())
18415    });
18416    if bytes > max_log_bytes {
18417        return Err(SubscriberError::ResourceExhausted(format!(
18418            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
18419        )));
18420    }
18421    Ok(bytes)
18422}
18423
18424async fn fetch_provider_block_ref<P, N>(
18425    provider: &P,
18426    number: u64,
18427) -> Result<BlockRef, SubscriberError>
18428where
18429    P: Provider<N> + Send + Sync,
18430    N: Network,
18431{
18432    let block = provider
18433        .get_block_by_number(BlockNumberOrTag::Number(number))
18434        .await
18435        .map_err(provider_error)?
18436        .ok_or_else(|| {
18437            SubscriberError::InvalidBackfill(format!(
18438                "canonical target block {number} is unavailable"
18439            ))
18440        })?;
18441    let header = block.header();
18442    Ok(BlockRef {
18443        number: header.number(),
18444        hash: header.hash(),
18445        parent_hash: Some(header.parent_hash()),
18446        timestamp: Some(header.timestamp()),
18447    })
18448}
18449
18450fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
18451    actual.number == expected.number
18452        && actual.hash == expected.hash
18453        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
18454        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
18455}
18456
18457fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
18458    let mut positions = HashMap::new();
18459    let mut block_hashes = HashMap::new();
18460    let mut transaction_hashes = HashMap::new();
18461    let mut transaction_positions = HashMap::new();
18462    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
18463    for log in logs {
18464        let number = log
18465            .block_number
18466            .expect("individual owner catch-up logs are validated before set validation");
18467        let block_hash = log
18468            .block_hash
18469            .expect("individual owner catch-up logs are validated before set validation");
18470        let transaction_hash = log
18471            .transaction_hash
18472            .expect("individual owner catch-up logs are validated before set validation");
18473        let transaction_index = log
18474            .transaction_index
18475            .expect("individual owner catch-up logs are validated before set validation");
18476        let log_index = log
18477            .log_index
18478            .expect("individual owner catch-up logs are validated before set validation");
18479        if block_hashes
18480            .insert(number, block_hash)
18481            .is_some_and(|prior| prior != block_hash)
18482        {
18483            return Err(SubscriberOwnerError::InvalidBackfillLog(
18484                "conflicting block identity in canonical catch-up",
18485            ));
18486        }
18487        if let Some(previous) = positions.insert((number, log_index), log)
18488            && previous != log
18489        {
18490            return Err(SubscriberOwnerError::InvalidBackfillLog(
18491                "conflicting logs at one canonical block position",
18492            ));
18493        }
18494        let conflicting_transaction = transaction_hashes
18495            .insert((number, transaction_index), transaction_hash)
18496            .is_some_and(|prior| prior != transaction_hash)
18497            || transaction_positions
18498                .insert((number, transaction_hash), transaction_index)
18499                .is_some_and(|prior| prior != transaction_index);
18500        if conflicting_transaction {
18501            return Err(SubscriberOwnerError::InvalidBackfillLog(
18502                "conflicting transaction identity at one canonical block position",
18503            ));
18504        }
18505        ordering
18506            .entry(number)
18507            .or_default()
18508            .push((log_index, transaction_index));
18509    }
18510    for positions in ordering.values_mut() {
18511        positions.sort_unstable();
18512        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
18513            return Err(SubscriberOwnerError::InvalidBackfillLog(
18514                "transaction and log positions disagree on canonical order",
18515            ));
18516        }
18517    }
18518    Ok(())
18519}
18520
18521fn merged_owner_reconcile_filters<N: Network>(
18522    plans: &[SubscriberOwnerReconcilePlan<N>],
18523    through: u64,
18524) -> Vec<SubscriberOwnerReconcileFilter> {
18525    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
18526    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
18527        let filters = by_start.entry(plan.from_block).or_default();
18528        filters.extend(
18529            log_filters(&plan.interests)
18530                .into_iter()
18531                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
18532        );
18533    }
18534
18535    let mut chunks = Vec::new();
18536    for (from_block, filters) in by_start {
18537        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
18538            let mut merged = Vec::new();
18539            for filter in filters {
18540                merge_log_subscription_filter(&mut merged, filter);
18541            }
18542            chunks.extend(
18543                merged
18544                    .into_iter()
18545                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
18546            );
18547        }
18548    }
18549    chunks
18550}
18551
18552fn merged_lazy_backfill_filters(
18553    filters: &[Filter],
18554    from_block: u64,
18555    through: u64,
18556) -> Vec<SubscriberOwnerReconcileFilter> {
18557    let mut requests = Vec::new();
18558    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
18559        let mut merged = Vec::new();
18560        for filter in filters {
18561            merge_log_subscription_filter(
18562                &mut merged,
18563                &filter.clone().from_block(from_block).to_block(through),
18564            );
18565        }
18566        requests.extend(
18567            merged
18568                .into_iter()
18569                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
18570        );
18571    }
18572    requests
18573}
18574
18575fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
18576    match error {
18577        SubscriberOwnerError::Subscriber(error) => error,
18578        error => SubscriberError::InvalidBackfill(error.to_string()),
18579    }
18580}
18581
18582fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
18583    let mut id = b"alloy-global-backfill-v1".to_vec();
18584    id.extend_from_slice(&backfill.start_block().to_be_bytes());
18585    id.extend_from_slice(&certified.number.to_be_bytes());
18586    id.extend_from_slice(certified.hash.as_slice());
18587    ChainControl::Barrier {
18588        id,
18589        block: Some(certified),
18590    }
18591}
18592
18593async fn fetch_owner_catchup<P, N>(
18594    provider: P,
18595    filters: Vec<SubscriberOwnerReconcileFilter>,
18596    retained: Vec<BlockRef>,
18597    through: BlockRef,
18598    options: SubscriberOwnerCatchupOptions,
18599) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
18600where
18601    P: Provider<N> + Send + Sync,
18602    N: Network,
18603{
18604    if !options.target_preverified {
18605        let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
18606    }
18607    let mut certified_positions = HashSet::new();
18608    for position in retained {
18609        let target_certifies_position = position == through
18610            || (position.number.checked_add(1) == Some(through.number)
18611                && through.parent_hash == Some(position.hash));
18612        if !target_certifies_position && certified_positions.insert(position) {
18613            let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
18614        }
18615    }
18616    let mut logs = Vec::new();
18617    let mut total_log_bytes = 0usize;
18618    let requests = stream::iter(filters.into_iter().map(|filter| {
18619        let provider = &provider;
18620        async move {
18621            let logs = provider
18622                .get_logs(&filter.filter)
18623                .await
18624                .map_err(provider_error)?;
18625            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
18626        }
18627    }))
18628    .buffer_unordered(options.max_requests_in_flight);
18629    futures::pin_mut!(requests);
18630    while let Some(result) = requests.next().await {
18631        let (from_block, fetched) = result?;
18632        let fetched_bytes =
18633            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
18634        validate_owner_backfill_logs(&fetched, from_block, &through)?;
18635        if logs.len().saturating_add(fetched.len()) > options.max_logs {
18636            return Err(SubscriberError::ResourceExhausted(format!(
18637                "bulk reconcile returned more than {} logs",
18638                options.max_logs
18639            ))
18640            .into());
18641        }
18642        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
18643        if total_log_bytes > options.max_log_bytes {
18644            return Err(SubscriberError::ResourceExhausted(format!(
18645                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
18646                options.max_log_bytes
18647            ))
18648            .into());
18649        }
18650        logs.extend(fetched);
18651    }
18652    validate_owner_backfill_log_set(&logs)?;
18653    let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
18654    Ok(SubscriberOwnerCatchup { logs, certified })
18655}
18656
18657async fn verify_provider_reconcile_target<P, N>(
18658    provider: &P,
18659    expected: &BlockRef,
18660) -> Result<BlockRef, SubscriberOwnerError>
18661where
18662    P: Provider<N> + Send + Sync,
18663    N: Network,
18664{
18665    let block = provider
18666        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
18667        .await
18668        .map_err(provider_error)?
18669        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
18670    let header = block.header();
18671    let actual = BlockRef {
18672        number: header.number(),
18673        hash: header.hash(),
18674        parent_hash: Some(header.parent_hash()),
18675        timestamp: Some(header.timestamp()),
18676    };
18677    let exact_parent = expected
18678        .parent_hash
18679        .is_none_or(|parent| Some(parent) == actual.parent_hash);
18680    let exact_timestamp = expected
18681        .timestamp
18682        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
18683    if actual.number != expected.number
18684        || actual.hash != expected.hash
18685        || !exact_parent
18686        || !exact_timestamp
18687    {
18688        return Err(SubscriberOwnerError::BlockMismatch {
18689            expected_number: expected.number,
18690            expected_hash: expected.hash,
18691            actual_number: actual.number,
18692            actual_hash: actual.hash,
18693        });
18694    }
18695    Ok(actual)
18696}
18697
18698fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
18699    let context = log_reactive_context(&log);
18700    ReactiveInputRecord::new(
18701        ReactiveInput::Log(log),
18702        ReactiveContext { source, ..context },
18703    )
18704}
18705
18706fn preconfirmed_log_input_record<N: Network>(
18707    log: Log,
18708    flashblock: FlashblockRef,
18709) -> ReactiveInputRecord<N> {
18710    let block = flashblock.block_ref();
18711    let provider = flashblock.provider.clone();
18712    ReactiveInputRecord::new(
18713        ReactiveInput::Log(log.clone()),
18714        ReactiveContext {
18715            chain_id: None,
18716            source: InputSource::Flashblocks,
18717            chain_status: ChainStatus::Preconfirmed { flashblock },
18718            block: Some(block),
18719            transaction_index: log.transaction_index,
18720            log_index: log.log_index,
18721        },
18722    )
18723    .with_provider(provider)
18724}
18725
18726fn log_reactive_context(log: &Log) -> ReactiveContext {
18727    let block = match (log.block_hash, log.block_number) {
18728        (Some(hash), Some(number)) => Some(BlockRef {
18729            number,
18730            hash,
18731            parent_hash: None,
18732            timestamp: log.block_timestamp,
18733        }),
18734        _ => None,
18735    };
18736
18737    let chain_status = match (&block, log.removed) {
18738        (Some(block), true) => ChainStatus::Reorged {
18739            dropped_from: *block,
18740        },
18741        (Some(block), false) => ChainStatus::Included {
18742            block: *block,
18743            confirmations: 0,
18744        },
18745        (None, _) => ChainStatus::Pending,
18746    };
18747
18748    ReactiveContext {
18749        chain_id: None,
18750        source: InputSource::Poll,
18751        chain_status,
18752        block,
18753        transaction_index: log.transaction_index,
18754        log_index: log.log_index,
18755    }
18756}
18757
18758fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
18759where
18760    N: Network,
18761{
18762    let block = BlockRef {
18763        number: header.number(),
18764        hash: HeaderResponseTrait::hash(&header),
18765        parent_hash: Some(header.parent_hash()),
18766        timestamp: Some(header.timestamp()),
18767    };
18768    ReactiveInputRecord::new(
18769        ReactiveInput::BlockHeader(header),
18770        ReactiveContext {
18771            chain_id: None,
18772            source: InputSource::Subscription,
18773            chain_status: ChainStatus::Included {
18774                block,
18775                confirmations: 0,
18776            },
18777            block: Some(block),
18778            transaction_index: None,
18779            log_index: None,
18780        },
18781    )
18782}
18783
18784fn pending_hash_input_record<N: Network>(
18785    hash: B256,
18786    source: InputSource,
18787) -> ReactiveInputRecord<N> {
18788    ReactiveInputRecord::new(
18789        ReactiveInput::PendingTxHash(hash),
18790        ReactiveContext {
18791            chain_id: None,
18792            source,
18793            chain_status: ChainStatus::Pending,
18794            block: None,
18795            transaction_index: None,
18796            log_index: None,
18797        },
18798    )
18799}
18800
18801#[cfg(feature = "reactive-ws")]
18802fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
18803    let encoded = serde_json::to_value(filter)
18804        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
18805    let serde_json::Value::Object(mut fields) = encoded else {
18806        return Err(SubscriberError::Provider(
18807            "Alloy log filter did not serialize as an object".into(),
18808        ));
18809    };
18810    fields.retain(|key, _| key == "address" || key == "topics");
18811    Ok(serde_json::Value::Object(fields))
18812}
18813
18814fn provider_error(error: impl fmt::Display) -> SubscriberError {
18815    SubscriberError::Provider(error.to_string())
18816}
18817
18818/// Subscriber error.
18819#[derive(Debug, thiserror::Error)]
18820#[non_exhaustive]
18821pub enum SubscriberError {
18822    /// Invalid subscriber configuration.
18823    #[error("{0}")]
18824    InvalidConfig(&'static str),
18825    /// Requested subscriber behavior is not implemented.
18826    #[error("{0}")]
18827    Unsupported(&'static str),
18828    /// Provider or transport error.
18829    #[error("provider error: {0}")]
18830    Provider(String),
18831    /// A provider returned malformed, out-of-range, or non-canonical lazy
18832    /// backfill data.
18833    #[error("invalid canonical backfill: {0}")]
18834    InvalidBackfill(String),
18835    /// A configured subscriber memory/concurrency boundary was exceeded.
18836    #[error("subscriber resource limit exceeded: {0}")]
18837    ResourceExhausted(String),
18838}