Skip to main content

evm_fork_cache/reactive/
mod.rs

1//! Protocol-neutral reactive runtime for cache state effects.
2//!
3//! The reactive runtime generalizes the log-only [`events`](crate::events)
4//! pipeline into a handler pipeline that can ingest logs, block notifications,
5//! and pending transaction signals. Handlers remain pure synchronous functions:
6//! they read through [`StateView`], return structured
7//! [`ReactiveEffect`] values, and let the runtime validate and commit cache
8//! mutations through [`StateUpdate`].
9//!
10//! This module intentionally contains no protocol, AMM, strategy, signing, or
11//! transaction-submission concepts. Downstream crates can layer those domains on
12//! top by implementing [`ReactiveHandler`] and [`ReactiveHook`].
13
14use std::{
15    any::Any,
16    borrow::Cow,
17    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
18    fmt,
19    future::Future,
20    hash::Hash,
21    marker::PhantomData,
22    num::NonZeroU64,
23    path::PathBuf,
24    pin::Pin,
25    sync::{
26        Arc,
27        atomic::{AtomicU64, Ordering},
28    },
29    time::{Duration, Instant},
30};
31
32use alloy_consensus::{BlockHeader as _, Transaction as _};
33use alloy_eips::{BlockId, BlockNumberOrTag};
34use alloy_network::{
35    Ethereum, Network,
36    primitives::{
37        BlockResponse as _, HeaderResponse as HeaderResponseTrait,
38        TransactionResponse as TransactionResponseTrait,
39    },
40};
41use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256};
42use alloy_provider::{Provider, RootProvider};
43use alloy_rpc_client::BatchRequest;
44use alloy_rpc_types_eth::{Filter, FilterSet, Log};
45pub use alloy_transport_balancer::EndpointId;
46use bincode::Options;
47use futures::{StreamExt, stream};
48use futures::{
49    future::{Either, poll_fn, select},
50    stream::{BoxStream, FuturesUnordered},
51};
52
53use crate::{
54    cache::{
55        AccountProof, BlockStateDiff, DurableCheckpointBlock, DurableCheckpointError,
56        DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache,
57        EvmCacheStateSnapshot, LoadedDurableCheckpoint,
58    },
59    errors::{BlockContextError, StorageFetchResult},
60    events::{EventDecoder, StateView},
61    freshness::FreshnessRegistry,
62    state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
63};
64
65/// Input accepted by the reactive runtime.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum ReactiveInput<N: Network = Ethereum> {
68    /// A canonical or removed EVM log, using Alloy's RPC log type.
69    Log(Log),
70    /// A block header response for header-oriented handlers.
71    BlockHeader(N::HeaderResponse),
72    /// A full block response for block handlers that need transaction bodies.
73    FullBlock(N::BlockResponse),
74    /// A pending transaction hash.
75    PendingTxHash(B256),
76    /// A full pending transaction body.
77    PendingTx(N::TransactionResponse),
78}
79
80/// Context supplied with each [`ReactiveInput`].
81#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82pub struct ReactiveContext {
83    /// Chain id, when known.
84    pub chain_id: Option<u64>,
85    /// Where the input came from.
86    pub source: InputSource,
87    /// Lifecycle status of the input.
88    pub chain_status: ChainStatus,
89    /// Block metadata associated with the input, when known.
90    pub block: Option<BlockRef>,
91    /// Transaction index for log or transaction inputs.
92    pub transaction_index: Option<u64>,
93    /// Log index for log inputs.
94    pub log_index: Option<u64>,
95}
96
97/// Minimal block identity carried through reports.
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
99pub struct BlockRef {
100    /// Block number.
101    pub number: u64,
102    /// Block hash.
103    pub hash: B256,
104    /// Parent hash, when known.
105    pub parent_hash: Option<B256>,
106    /// Block timestamp, when known.
107    pub timestamp: Option<u64>,
108}
109
110/// Stable provider identity attached to provider-originated input.
111///
112/// `generation` changes whenever a caller replaces or reconnects the concrete
113/// provider session behind the same configured endpoint. Follow-up reads can
114/// use this value to prefer the exact source that announced speculative state
115/// without putting URLs or credentials into event payloads.
116#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
117pub struct ProviderRef {
118    /// Operator-defined endpoint identity.
119    pub endpoint: EndpointId,
120    /// Concrete connection/session generation.
121    pub generation: u64,
122}
123
124impl ProviderRef {
125    /// Construct provider provenance for one connection generation.
126    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
127        Self {
128            endpoint: endpoint.into(),
129            generation,
130        }
131    }
132}
133
134/// Identity of one cumulative pre-confirmed Flashblock snapshot.
135#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
136pub struct FlashblockRef {
137    /// Provider session that supplied this snapshot.
138    pub provider: ProviderRef,
139    /// Sequencer payload id shared by every Flashblock in the full block.
140    ///
141    /// Some provider wire shapes omit this indexed-payload 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    /// Provider-generation-scoped commitment to this exact cumulative view.
148    ///
149    /// This is deliberately not a canonical or provider-reported block hash.
150    /// It remains non-zero even when a pending endpoint uses the zero hash
151    /// placeholder permitted by the Flashblocks specification.
152    pub content_hash: B256,
153    /// Non-placeholder partial block hash reported by the provider, when any.
154    pub partial_block_hash: Option<B256>,
155    /// Canonical parent of the pending block, when exposed.
156    pub parent_hash: Option<B256>,
157    /// State root after this cumulative snapshot, when exposed.
158    pub state_root: Option<B256>,
159    /// Transaction-trie root committed by a cumulative block-shaped preview.
160    pub transactions_root: Option<B256>,
161    /// Ordered cumulative transaction membership for this preview.
162    pub transaction_hashes: Vec<B256>,
163    /// Pending block timestamp, when exposed.
164    pub timestamp: Option<u64>,
165    /// Pending EIP-1559 base fee, when exposed.
166    pub base_fee_per_gas: Option<u64>,
167    /// Pending block beneficiary / fee recipient, when exposed.
168    pub beneficiary: Option<Address>,
169    /// Pending block randomness value, when exposed.
170    pub prevrandao: Option<B256>,
171    /// Pending block gas limit, when exposed.
172    pub gas_limit: Option<u64>,
173}
174
175impl FlashblockRef {
176    /// Convert the pre-confirmed identity into the block metadata used by
177    /// ordinary log routing. The hash is the provider-generation-scoped
178    /// [`content_hash`](Self::content_hash), never a canonical block hash, and
179    /// must not advance canonical coverage.
180    pub const fn block_ref(&self) -> BlockRef {
181        BlockRef {
182            number: self.block_number,
183            hash: self.content_hash,
184            parent_hash: self.parent_hash,
185            timestamp: self.timestamp,
186        }
187    }
188
189    /// Whether the cumulative preview contains `transaction_hash`.
190    pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
191        self.transaction_hashes.contains(transaction_hash)
192    }
193
194    fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
195        self.transaction_hashes
196            .iter()
197            .position(|candidate| candidate == transaction_hash)
198            .and_then(|index| u64::try_from(index).ok())
199    }
200
201    fn same_payload(&self, other: &Self) -> bool {
202        self.provider == other.provider
203            && match (self.payload_id, other.payload_id) {
204                (Some(left), Some(right)) => left == right,
205                _ => {
206                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
207                }
208            }
209    }
210
211    fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
212        self.same_payload(previous)
213            && self.transaction_hashes.len() >= previous.transaction_hashes.len()
214            && self
215                .transaction_hashes
216                .starts_with(&previous.transaction_hashes)
217            && match (previous.index, self.index) {
218                (Some(previous), Some(current)) => current >= previous,
219                _ => true,
220            }
221    }
222}
223
224/// Whether the subscriber may use Flashblocks for speculative delivery.
225#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
226pub enum PreconfirmationMode {
227    /// Use only canonical subscription/polling behavior.
228    #[default]
229    Disabled,
230    /// Prefer Flashblocks, but retain canonical operation when the selected
231    /// chain/provider cannot establish the pre-confirmation stream.
232    Preferred,
233    /// Fail setup/reconnect closed unless Flashblocks can be established.
234    Required,
235}
236
237/// Indexed OP Stack `newFlashblocks` subscription payload.
238#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
239pub struct BaseFlashblockPayload {
240    /// Block-builder payload id shared by every incremental snapshot.
241    pub payload_id: FixedBytes<8>,
242    /// Zero-based incremental snapshot index.
243    pub index: u64,
244    /// Header fields present on index zero.
245    pub base: Option<BaseFlashblockBase>,
246    /// Cumulative state commitments for this snapshot.
247    pub diff: BaseFlashblockDiff,
248    /// Supplemental block identity retained across current Base versions.
249    #[serde(default)]
250    pub metadata: Option<BaseFlashblockMetadata>,
251}
252
253/// Stable index-zero header subset from Base's Flashblocks wire format.
254#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
255pub struct BaseFlashblockBase {
256    /// Canonical parent block hash.
257    pub parent_hash: B256,
258    /// Pending block number.
259    #[serde(deserialize_with = "deserialize_rpc_u64")]
260    pub block_number: u64,
261    /// Pending block timestamp.
262    #[serde(deserialize_with = "deserialize_rpc_u64")]
263    pub timestamp: u64,
264    /// Pending block gas limit.
265    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
266    pub gas_limit: Option<u64>,
267    /// Pending EIP-1559 base fee.
268    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
269    pub base_fee_per_gas: Option<u64>,
270    /// Pending block beneficiary / fee recipient.
271    #[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
272    pub beneficiary: Option<Address>,
273    /// Pending block randomness value.
274    #[serde(
275        default,
276        alias = "prev_randao",
277        alias = "prevRandao",
278        alias = "mixHash"
279    )]
280    pub prevrandao: Option<B256>,
281}
282
283/// Stable commitment subset from Base's Flashblocks wire format.
284#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
285pub struct BaseFlashblockDiff {
286    /// State root after this cumulative snapshot.
287    pub state_root: B256,
288    /// Partial block hash after this cumulative snapshot.
289    pub block_hash: B256,
290    /// Transactions added by this indexed Flashblock diff.
291    #[serde(default)]
292    pub transactions: Vec<serde_json::Value>,
293    /// Transaction root when exposed by the provider.
294    #[serde(default)]
295    pub transactions_root: Option<B256>,
296}
297
298/// Stable metadata subset used when index-greater-than-zero payloads omit the
299/// Base header object.
300#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
301pub struct BaseFlashblockMetadata {
302    /// Pending block number (currently encoded as a JSON integer).
303    #[serde(deserialize_with = "deserialize_rpc_u64")]
304    pub block_number: u64,
305}
306
307/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack
308/// providers.
309#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
310#[serde(rename_all = "camelCase")]
311struct BaseFlashblockBlockPayload {
312    hash: B256,
313    #[serde(deserialize_with = "deserialize_rpc_u64")]
314    number: u64,
315    parent_hash: B256,
316    state_root: B256,
317    #[serde(default)]
318    transactions_root: Option<B256>,
319    #[serde(default)]
320    transactions: Vec<serde_json::Value>,
321    #[serde(deserialize_with = "deserialize_rpc_u64")]
322    timestamp: u64,
323    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
324    base_fee_per_gas: Option<u64>,
325    #[serde(default, alias = "beneficiary", alias = "feeRecipient")]
326    miner: Option<Address>,
327    #[serde(default, alias = "prevRandao")]
328    mix_hash: Option<B256>,
329    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
330    gas_limit: Option<u64>,
331}
332
333/// OP Stack providers expose either an indexed diff envelope or a cumulative
334/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
335/// differences do not force callers onto separate subscriber paths.
336#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
337#[serde(untagged)]
338enum BaseFlashblockWirePayload {
339    Indexed(BaseFlashblockPayload),
340    Block(BaseFlashblockBlockPayload),
341}
342
343fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
344where
345    D: serde::Deserializer<'de>,
346{
347    #[derive(serde::Deserialize)]
348    #[serde(untagged)]
349    enum RpcU64 {
350        Number(u64),
351        String(String),
352    }
353
354    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
355        RpcU64::Number(number) => Ok(number),
356        RpcU64::String(value) => {
357            let value = value.strip_prefix("0x").unwrap_or(&value);
358            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
359        }
360    }
361}
362
363fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
364where
365    D: serde::Deserializer<'de>,
366{
367    #[derive(serde::Deserialize)]
368    #[serde(untagged)]
369    enum RpcU64 {
370        Number(u64),
371        String(String),
372    }
373
374    let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
375        return Ok(None);
376    };
377    match value {
378        RpcU64::Number(number) => Ok(Some(number)),
379        RpcU64::String(value) => {
380            let value = value.strip_prefix("0x").unwrap_or(&value);
381            u64::from_str_radix(value, 16)
382                .map(Some)
383                .map_err(serde::de::Error::custom)
384        }
385    }
386}
387
388fn non_placeholder_hash(hash: B256) -> Option<B256> {
389    (!hash.is_zero()).then_some(hash)
390}
391
392fn flashblock_transaction_hashes(
393    transactions: &[serde_json::Value],
394) -> Result<Vec<B256>, SubscriberError> {
395    let hashes: Vec<B256> = transactions
396        .iter()
397        .map(|transaction| {
398            let value = match transaction {
399                serde_json::Value::String(value) => value.as_str(),
400                serde_json::Value::Object(object) => object
401                    .get("hash")
402                    .or_else(|| object.get("transactionHash"))
403                    .and_then(serde_json::Value::as_str)
404                    .ok_or_else(|| {
405                        SubscriberError::Provider(
406                            "Flashblock transaction object is missing its hash".into(),
407                        )
408                    })?,
409                _ => {
410                    return Err(SubscriberError::Provider(
411                        "Flashblock transaction must be a hash, raw transaction, or object".into(),
412                    ));
413                }
414            };
415            if value.len() == 66 {
416                return value.parse::<B256>().map_err(|error| {
417                    SubscriberError::Provider(format!(
418                        "Flashblock transaction hash is invalid: {error}"
419                    ))
420                });
421            }
422            let encoded = value.strip_prefix("0x").unwrap_or(value);
423            let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
424                SubscriberError::Provider(format!(
425                    "Flashblock raw transaction is invalid hex: {error}"
426                ))
427            })?;
428            Ok(alloy_primitives::keccak256(raw))
429        })
430        .collect::<Result<_, _>>()?;
431    let mut unique = HashSet::with_capacity(hashes.len());
432    if hashes.iter().any(|hash| !unique.insert(*hash)) {
433        return Err(SubscriberError::Provider(
434            "Flashblock cumulative transaction membership contains a duplicate hash".into(),
435        ));
436    }
437    Ok(hashes)
438}
439
440struct FlashblockContentCommitment<'a> {
441    provider: &'a ProviderRef,
442    payload_id: Option<FixedBytes<8>>,
443    index: Option<u64>,
444    block_number: u64,
445    partial_block_hash: Option<B256>,
446    parent_hash: Option<B256>,
447    state_root: Option<B256>,
448    transactions_root: Option<B256>,
449    transaction_hashes: &'a [B256],
450    timestamp: Option<u64>,
451    base_fee_per_gas: Option<u64>,
452    beneficiary: Option<Address>,
453    prevrandao: Option<B256>,
454    gas_limit: Option<u64>,
455}
456
457fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
458    let mut commitment = Keccak256::new();
459    commitment.update(b"evm-fork-cache/flashblock-content/v1");
460    let endpoint = content.provider.endpoint.as_str().as_bytes();
461    commitment.update((endpoint.len() as u64).to_be_bytes());
462    commitment.update(endpoint);
463    commitment.update(content.provider.generation.to_be_bytes());
464    commitment.update(content.block_number.to_be_bytes());
465    commit_optional_bytes(
466        &mut commitment,
467        content.payload_id.as_ref().map(FixedBytes::as_slice),
468    );
469    commit_optional_u64(&mut commitment, content.index);
470    commit_optional_bytes(
471        &mut commitment,
472        content
473            .partial_block_hash
474            .as_ref()
475            .map(FixedBytes::as_slice),
476    );
477    commit_optional_bytes(
478        &mut commitment,
479        content.parent_hash.as_ref().map(FixedBytes::as_slice),
480    );
481    commit_optional_bytes(
482        &mut commitment,
483        content.state_root.as_ref().map(FixedBytes::as_slice),
484    );
485    commit_optional_bytes(
486        &mut commitment,
487        content.transactions_root.as_ref().map(FixedBytes::as_slice),
488    );
489    commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
490    for transaction_hash in content.transaction_hashes {
491        commitment.update(transaction_hash);
492    }
493    commit_optional_u64(&mut commitment, content.timestamp);
494    commit_optional_u64(&mut commitment, content.base_fee_per_gas);
495    commit_optional_bytes(
496        &mut commitment,
497        content
498            .beneficiary
499            .as_ref()
500            .map(|address| address.as_slice()),
501    );
502    commit_optional_bytes(
503        &mut commitment,
504        content.prevrandao.as_ref().map(FixedBytes::as_slice),
505    );
506    commit_optional_u64(&mut commitment, content.gas_limit);
507    let hash = commitment.finalize();
508    if hash.is_zero() {
509        B256::with_last_byte(1)
510    } else {
511        hash
512    }
513}
514
515fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
516    match value {
517        Some(value) => {
518            commitment.update([1]);
519            commitment.update((value.len() as u64).to_be_bytes());
520            commitment.update(value);
521        }
522        None => commitment.update([0]),
523    }
524}
525
526fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
527    match value {
528        Some(value) => {
529            commitment.update([1]);
530            commitment.update(value.to_be_bytes());
531        }
532        None => commitment.update([0]),
533    }
534}
535
536/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
537/// point for reactive event continuity.
538#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
539pub struct ReactiveCanonicalBaseline {
540    /// Chain whose state the cache snapshot contains.
541    pub chain_id: u64,
542    /// Canonical block through which the snapshot already embodies state.
543    pub block: BlockRef,
544}
545
546impl ReactiveCanonicalBaseline {
547    /// Construct an exact cache snapshot baseline.
548    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
549        Self { chain_id, block }
550    }
551}
552
553/// Ordered chain-lifecycle control delivered by an event subscriber.
554///
555/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
556/// token, durable checkpoint, and ordering guarantees as ordinary event data.
557/// Reorg controls are applied in declaration order before replacement records;
558/// progress, barrier, safe, and finalized controls are committed in declaration
559/// order after the records. A reorg declared after a post-record control is
560/// rejected because its ordering would otherwise be ambiguous.
561#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
562#[non_exhaustive]
563pub enum ChainControl {
564    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
565    Reorg {
566        /// Last block common to the old and new canonical branches.
567        common_ancestor: BlockRef,
568        /// Tip of the branch that ceased to be canonical.
569        old_tip: BlockRef,
570        /// Tip of the newly canonical branch known by the source.
571        new_tip: BlockRef,
572    },
573    /// Update the source's safe head.
574    Safe(BlockRef),
575    /// Update the source's finalized head.
576    Finalized(BlockRef),
577    /// Advance authoritative canonical coverage without fabricating a full header.
578    ///
579    /// Indexers that only know compact block identity should emit this control.
580    /// It never runs block handlers. The runtime exact-hash pins provider reads
581    /// and installs known `NUMBER`/timestamp values, but clears unproven
582    /// header-only environment fields such as base fee and beneficiary.
583    CanonicalProgress(BlockRef),
584    /// Ordered cutover or synchronization fence.
585    Barrier {
586        /// Subscriber-defined opaque barrier identity.
587        id: Vec<u8>,
588        /// Highest canonical event block included before the fence, if known.
589        block: Option<BlockRef>,
590    },
591}
592
593/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
594///
595/// Composite subscribers can persist this small chain-state view beside their
596/// own delivery checkpoint and validate a complete delivery envelope before it
597/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
598/// without matching events need not be present), but it must contain at most
599/// one compatible identity per height. Its oldest entry is also the durable
600/// rollback horizon: an unretained explicit ancestor is accepted only when that
601/// oldest entry is at or below the ancestor. This type carries no cache data,
602/// event payloads, handler state, or transport-specific cursor.
603///
604/// The serde representation is a convenience for caller-owned persistence; it
605/// is not a versioned wire or checkpoint format. Durable protocols should wrap
606/// it in their own versioned envelope and define migrations before upgrading
607/// this pre-1.0 crate. External callers also own retention: successful
608/// validation appends canonical identities but does not silently discard the
609/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
610/// committing the matching source cursor/ACK.
611#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
612pub struct CanonicalSequenceState {
613    retained_canonical_history: Vec<BlockRef>,
614    coverage_head: Option<BlockRef>,
615    safe_head: Option<BlockRef>,
616    finalized_head: Option<BlockRef>,
617}
618
619impl CanonicalSequenceState {
620    /// Construct a validation snapshot from retained canonical metadata.
621    ///
622    /// Construction does not validate ordering, adjacency, coverage, or
623    /// finality invariants. Call [`Self::validate`] before installing decoded or
624    /// externally assembled state.
625    pub fn new(
626        retained_canonical_history: Vec<BlockRef>,
627        coverage_head: Option<BlockRef>,
628        safe_head: Option<BlockRef>,
629        finalized_head: Option<BlockRef>,
630    ) -> Self {
631        Self {
632            retained_canonical_history,
633            coverage_head,
634            safe_head,
635            finalized_head,
636        }
637    }
638
639    /// Sparse retained canonical history in ascending processing order.
640    pub fn retained_canonical_history(&self) -> &[BlockRef] {
641        &self.retained_canonical_history
642    }
643
644    /// Highest canonical identity covered by this state, when known.
645    pub const fn coverage_head(&self) -> Option<&BlockRef> {
646        self.coverage_head.as_ref()
647    }
648
649    /// Latest safe head accepted by the validator, when known.
650    pub const fn safe_head(&self) -> Option<&BlockRef> {
651        self.safe_head.as_ref()
652    }
653
654    /// Latest finalized head accepted by the validator, when known.
655    pub const fn finalized_head(&self) -> Option<&BlockRef> {
656        self.finalized_head.as_ref()
657    }
658
659    /// Retain at most the newest `max_entries` canonical history identities.
660    ///
661    /// Coverage and safe/finalized heads are unchanged. The oldest retained
662    /// identity defines how far strict validation can prove a complete
663    /// rollback, so choose a bound at least as large as the deployment's
664    /// supported reorg depth and trim only after atomically committing the
665    /// corresponding validated state and source cursor. `0` intentionally
666    /// produces a coverage-only snapshot.
667    pub fn retain_recent_history(&mut self, max_entries: usize) {
668        let remove = self
669            .retained_canonical_history
670            .len()
671            .saturating_sub(max_entries);
672        self.retained_canonical_history.drain(..remove);
673    }
674
675    /// Validate a decoded/checkpointed snapshot before installing it.
676    ///
677    /// This rejects out-of-order or conflicting retained identities,
678    /// broken adjacent parent links, retained history without coverage,
679    /// incompatible coverage/finality aliases, hash reuse across heights,
680    /// known parent hashes at non-adjacent heights, finality beyond coverage,
681    /// and a finalized head beyond or conflicting with the safe head.
682    ///
683    /// # Errors
684    ///
685    /// Returns [`ReactiveError`] when any retained identity, parent link,
686    /// coverage alias, or safe/finalized relationship violates the canonical
687    /// snapshot invariants described above.
688    pub fn validate(&self) -> Result<(), ReactiveError> {
689        validate_canonical_sequence_snapshot(self)
690    }
691}
692
693/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
694#[derive(Clone, Debug, PartialEq, Eq)]
695#[non_exhaustive]
696pub enum CanonicalSequenceMutation {
697    /// Rewind the listed retained identities and continue from `common_ancestor`.
698    Rewind {
699        /// Surviving canonical anchor, when one is retained or authenticated.
700        /// `None` is a transient same-envelope state: callers must stage the
701        /// complete validation atomically and may checkpoint only the returned
702        /// `next_state`, after a later canonical mutation installs the proven
703        /// replacement.
704        common_ancestor: Option<BlockRef>,
705        /// Exact retained identities removed by the transition.
706        dropped: Vec<BlockRef>,
707    },
708    /// Accept or enrich one canonical identity.
709    Canonical(BlockRef),
710    /// Accept a safe-head update with metadata resolved against prior state.
711    Safe(BlockRef),
712    /// Accept a finalized-head update with metadata resolved against prior state.
713    Finalized(BlockRef),
714}
715
716/// Successful result of provider-neutral canonical envelope validation.
717#[derive(Clone, Debug, PartialEq, Eq)]
718pub struct CanonicalSequenceValidation {
719    pre_record_state: CanonicalSequenceState,
720    next_state: CanonicalSequenceState,
721    mutations: Vec<CanonicalSequenceMutation>,
722    normalized_chain_controls: Vec<ChainControl>,
723}
724
725impl CanonicalSequenceValidation {
726    /// State after pre-record explicit reorg controls and before event records.
727    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
728        &self.pre_record_state
729    }
730
731    /// Fully validated state after records and post-record controls.
732    pub const fn next_state(&self) -> &CanonicalSequenceState {
733        &self.next_state
734    }
735
736    /// Ordered cache-free canonical mutations proven by this envelope.
737    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
738        &self.mutations
739    }
740
741    /// Controls safe to forward after composite overlap normalization.
742    ///
743    /// Ordinary validation retains the original controls. See
744    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
745    /// compatible stale progress and converts a stale blockful barrier into the
746    /// same barrier identity without a block assertion. Equal-height controls
747    /// that add previously absent parent/timestamp metadata remain present;
748    /// older compatible enrichment is intentionally not applied because the
749    /// corresponding regressive control is not forwarded to the runtime.
750    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
751        &self.normalized_chain_controls
752    }
753}
754
755/// Lifecycle status for an input.
756#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
757#[non_exhaustive]
758pub enum ChainStatus {
759    /// The input is mempool-only and must not mutate canonical cache state.
760    Pending,
761    /// The input is ordered into an ephemeral sequencer-built Flashblock.
762    ///
763    /// Handlers may update the runtime's speculative overlay for this status,
764    /// but the update never advances canonical coverage or durable journals.
765    Preconfirmed {
766        /// Shared exact cumulative pre-confirmation snapshot observed by the
767        /// source. Sharing keeps ordinary canonical records compact and makes
768        /// multi-log Flashblock delivery cheap to clone.
769        flashblock: Arc<FlashblockRef>,
770    },
771    /// The input is included in a block with a confirmation count.
772    Included {
773        /// Included block.
774        block: BlockRef,
775        /// Confirmation count.
776        confirmations: u64,
777    },
778    /// The input is in the chain's safe head.
779    Safe {
780        /// Safe block.
781        block: BlockRef,
782    },
783    /// The input is in the finalized head.
784    Finalized {
785        /// Finalized block.
786        block: BlockRef,
787    },
788    /// The input was dropped by a reorg.
789    Reorged {
790        /// Block the input was dropped from.
791        dropped_from: BlockRef,
792    },
793}
794
795/// Source of an input batch.
796#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
797#[non_exhaustive]
798pub enum InputSource {
799    /// Caller-supplied batch.
800    Batch,
801    /// Live subscription stream.
802    Subscription,
803    /// Polling subscriber.
804    Poll,
805    /// Historical backfill.
806    Backfill,
807    /// Sequencer pre-confirmation / Flashblocks surface.
808    Flashblocks,
809    /// Test or synthetic input.
810    Synthetic,
811}
812
813/// Stable identity used for input deduplication and reports.
814#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
815pub enum InputRef {
816    /// Stable log identity.
817    Log {
818        /// Chain id, when known.
819        chain_id: Option<u64>,
820        /// Block hash containing the log.
821        block_hash: B256,
822        /// Transaction hash that emitted the log.
823        transaction_hash: B256,
824        /// Log index within the block.
825        log_index: u64,
826    },
827    /// Stable pending transaction identity.
828    PendingTx {
829        /// Chain id, when known.
830        chain_id: Option<u64>,
831        /// Transaction hash.
832        hash: B256,
833    },
834    /// Stable block identity.
835    Block {
836        /// Chain id, when known.
837        chain_id: Option<u64>,
838        /// Block hash.
839        hash: B256,
840        /// Block number.
841        number: u64,
842    },
843}
844
845/// Representation and lifecycle class retained alongside an [`InputRef`].
846///
847/// `InputRef` identifies the underlying chain object. This discriminator keeps
848/// distinct handler inputs from collapsing merely because they commit to the
849/// same object: a header and full block, a pending hash and hydrated body, and
850/// canonical versus reorg-signalling log delivery are independently routable.
851#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
852#[non_exhaustive]
853pub enum ReactiveInputKind {
854    /// Canonical log data.
855    CanonicalLog,
856    /// Removed or otherwise reorg-signalling log data.
857    ReorgSignalLog,
858    /// Header-only block representation.
859    BlockHeader,
860    /// Full block representation.
861    FullBlock,
862    /// Hash-only pending transaction representation.
863    PendingTxHash,
864    /// Hydrated pending transaction representation.
865    PendingTx,
866}
867
868/// Validated, representation-aware identity for one reactive input.
869///
870/// Composite subscribers can use this as a dedupe key without conflating
871/// independently routable representations. When a key repeats, use
872/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
873/// provider overlap from a conflicting payload.
874#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
875pub struct ReactiveInputIdentity {
876    input_ref: InputRef,
877    kind: ReactiveInputKind,
878}
879
880impl ReactiveInputIdentity {
881    /// Validate and construct an identity from explicit wire/codec parts.
882    ///
883    /// `InputRef` identifies the underlying object, while `kind` identifies its
884    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
885    /// block representations with [`InputRef::Block`], and pending-transaction
886    /// representations with [`InputRef::PendingTx`]. This constructor lets
887    /// external codecs rebuild the otherwise-private invariant without serde or
888    /// layout-dependent decoding.
889    ///
890    /// # Errors
891    ///
892    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
893    /// to the supplied representation `kind`.
894    pub fn try_from_parts(
895        input_ref: InputRef,
896        kind: ReactiveInputKind,
897    ) -> Result<Self, ReactiveInputIdentityError> {
898        let compatible = matches!(
899            (input_ref, kind),
900            (
901                InputRef::Log { .. },
902                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
903            ) | (
904                InputRef::Block { .. },
905                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
906            ) | (
907                InputRef::PendingTx { .. },
908                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
909            )
910        );
911        if !compatible {
912            return Err(ReactiveInputIdentityError { input_ref, kind });
913        }
914        Ok(Self { input_ref, kind })
915    }
916
917    /// Underlying stable chain-object reference.
918    pub const fn input_ref(&self) -> InputRef {
919        self.input_ref
920    }
921
922    /// Exact handler-input representation and lifecycle class.
923    pub const fn kind(&self) -> ReactiveInputKind {
924        self.kind
925    }
926}
927
928/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
929/// object/representation classes.
930#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
931#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
932pub struct ReactiveInputIdentityError {
933    input_ref: InputRef,
934    kind: ReactiveInputKind,
935}
936
937impl ReactiveInputIdentityError {
938    /// Rejected stable object reference.
939    pub const fn input_ref(&self) -> InputRef {
940        self.input_ref
941    }
942
943    /// Rejected representation/lifecycle kind.
944    pub const fn kind(&self) -> ReactiveInputKind {
945        self.kind
946    }
947}
948
949/// Reliability of state effects emitted by a handler.
950#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
951pub enum StateEffectQuality {
952    /// Effects are exact from the input alone.
953    ExactFromInput,
954    /// Effects were applied, but follow-up resync is pending.
955    AppliedWithPendingResync,
956    /// Effects came from authoritative resync.
957    ResyncedAuthoritatively,
958    /// State requires repair before it should be trusted.
959    RequiresRepair,
960    /// No canonical state effect was emitted.
961    NoStateEffect,
962}
963
964/// Identifier for a reactive handler.
965#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
966pub struct HandlerId(String);
967
968impl HandlerId {
969    /// Create a non-empty handler id.
970    ///
971    /// # Panics
972    ///
973    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
974    /// configuration or wire input.
975    pub fn new(id: impl Into<String>) -> Self {
976        Self::try_new(id).expect("handler id must not be empty")
977    }
978
979    /// Validate and create a handler id from untrusted input.
980    ///
981    /// # Errors
982    ///
983    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
984    /// reserved for canonical/global protocol scope.
985    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
986        let id = id.into();
987        if id.is_empty() {
988            return Err(HandlerIdError);
989        }
990        Ok(Self(id))
991    }
992
993    /// Return the id as a string slice.
994    pub fn as_str(&self) -> &str {
995        &self.0
996    }
997}
998
999impl<'de> serde::Deserialize<'de> for HandlerId {
1000    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1001    where
1002        D: serde::Deserializer<'de>,
1003    {
1004        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
1005        Self::try_new(id).map_err(serde::de::Error::custom)
1006    }
1007}
1008
1009/// An empty handler identity cannot be represented portably across subscriber
1010/// protocols because the empty owner is reserved for canonical/global scope.
1011#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1012#[error("handler id must not be empty")]
1013pub struct HandlerIdError;
1014
1015impl fmt::Display for HandlerId {
1016    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1017        self.0.fmt(f)
1018    }
1019}
1020
1021/// Lightweight report label.
1022#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1023pub struct ReportTag {
1024    /// Label key.
1025    pub key: String,
1026    /// Label value.
1027    pub value: String,
1028}
1029
1030impl ReportTag {
1031    /// Create a report tag.
1032    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1033        Self {
1034            key: key.into(),
1035            value: value.into(),
1036        }
1037    }
1038}
1039
1040/// Domain-neutral hook signal emitted by a handler.
1041#[derive(Clone)]
1042pub struct HookSignal {
1043    /// Signal namespace owned by the caller.
1044    pub namespace: Cow<'static, str>,
1045    /// Signal kind within the namespace.
1046    pub kind: Cow<'static, str>,
1047    /// Additional labels for routing or observability.
1048    pub labels: Vec<ReportTag>,
1049    /// Optional in-process typed payload.
1050    pub payload: Option<Arc<dyn Any + Send + Sync>>,
1051}
1052
1053impl fmt::Debug for HookSignal {
1054    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1055        f.debug_struct("HookSignal")
1056            .field("namespace", &self.namespace)
1057            .field("kind", &self.kind)
1058            .field("labels", &self.labels)
1059            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
1060            .finish()
1061    }
1062}
1063
1064/// Effect emitted by a [`ReactiveHandler`].
1065#[derive(Clone, Debug)]
1066pub enum ReactiveEffect {
1067    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
1068    StateUpdate(StateUpdate),
1069    /// Request for authoritative state repair.
1070    Resync(ResyncRequest),
1071    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
1072    Invalidate(InvalidationRequest),
1073    /// Hook signal dispatched after committed mutation phases.
1074    Hook(HookSignal),
1075    /// Speculative signal for mempool or downstream work.
1076    Speculative(SpeculativeRequest),
1077}
1078
1079/// Handler output for a single input.
1080#[derive(Clone, Debug)]
1081pub struct HandlerOutcome {
1082    /// Effects emitted by the handler.
1083    pub effects: Vec<ReactiveEffect>,
1084    /// Reliability of emitted state effects.
1085    pub quality: StateEffectQuality,
1086    /// Labels copied into reports.
1087    pub tags: Vec<ReportTag>,
1088}
1089
1090impl HandlerOutcome {
1091    /// Construct an empty outcome with the supplied quality.
1092    pub fn empty(quality: StateEffectQuality) -> Self {
1093        Self {
1094            effects: Vec::new(),
1095            quality,
1096            tags: Vec::new(),
1097        }
1098    }
1099}
1100
1101/// One input and its execution context.
1102#[derive(Clone, Debug)]
1103pub struct ReactiveInputRecord<N: Network = Ethereum> {
1104    /// Input value.
1105    pub input: ReactiveInput<N>,
1106    /// Input context.
1107    pub context: ReactiveContext,
1108    /// Provider session that originated this input, when it came from a
1109    /// concrete provider rather than a synthetic or aggregate source.
1110    pub provider: Option<ProviderRef>,
1111}
1112
1113impl<N: Network> ReactiveInputRecord<N> {
1114    /// Create an input record.
1115    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
1116        Self {
1117            input,
1118            context,
1119            provider: None,
1120        }
1121    }
1122
1123    /// Attach provider provenance used to route follow-up reads.
1124    #[must_use]
1125    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
1126        self.provider = Some(provider);
1127        self
1128    }
1129
1130    /// Compute the stable input reference used for deduplication.
1131    pub fn input_ref(&self) -> InputRef {
1132        input_ref(&self.input, &self.context)
1133    }
1134
1135    /// Validate payload/context coherence and return a representation-aware
1136    /// identity suitable for subscriber and runtime deduplication.
1137    ///
1138    /// Validation is fail-closed for canonical logs: their block, transaction,
1139    /// and log positions must be complete and agree with the context. Block and
1140    /// pending-transaction representations receive the corresponding lifecycle,
1141    /// inclusion-wrapper, and payload/context checks. This does not recompute a
1142    /// claimed header hash, transaction root, or transaction signature; exact
1143    /// subscriber payload commitments remain the transport-integrity boundary
1144    /// for those cryptographic claims.
1145    ///
1146    /// # Errors
1147    ///
1148    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
1149    /// lifecycle, inclusion metadata, or context is incomplete or internally
1150    /// inconsistent.
1151    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
1152        validate_input_record(self)?;
1153        let kind = match &self.input {
1154            ReactiveInput::Log(log)
1155                if log.removed
1156                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
1157            {
1158                ReactiveInputKind::ReorgSignalLog
1159            }
1160            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
1161            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
1162            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
1163            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
1164            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
1165        };
1166        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
1167            ReactiveError::InvalidInputRecord {
1168                message: error.to_string(),
1169            }
1170        })
1171    }
1172
1173    /// Whether two same-identity records carry the same deduplicable payload.
1174    ///
1175    /// This deliberately ignores [`ReactiveContext`]: the same provider object
1176    /// can legitimately arrive from backfill and subscription transports with
1177    /// different provenance or confirmation metadata. Callers must first
1178    /// compare [`validated_identity`](Self::validated_identity) and reconcile
1179    /// lifecycle/context authority separately. Logs are compared structurally;
1180    /// block and transaction hashes are cryptographic commitments for the
1181    /// remaining same-representation payloads. Full block responses and
1182    /// hydrated pending transaction bodies deliberately return `false`: the
1183    /// core does not currently prove a supplied body against the header's
1184    /// transaction root or compare every response field, so a composite source
1185    /// must preserve both rather than suppress one based only on its hash.
1186    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
1187        match (&self.input, &other.input) {
1188            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
1189                left.inner == right.inner
1190                    && left.block_hash == right.block_hash
1191                    && left.block_number == right.block_number
1192                    && optional_metadata_compatible(
1193                        left.block_timestamp.as_ref(),
1194                        right.block_timestamp.as_ref(),
1195                    )
1196                    && left.transaction_hash == right.transaction_hash
1197                    && left.transaction_index == right.transaction_index
1198                    && left.log_index == right.log_index
1199                    && left.removed == right.removed
1200            }
1201            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
1202                left.hash() == right.hash()
1203            }
1204            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
1205            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
1206                left == right
1207            }
1208            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
1209            _ => false,
1210        }
1211    }
1212
1213    /// Whether this representation has a complete payload-equivalence contract
1214    /// and may participate in duplicate suppression.
1215    ///
1216    /// Full block and hydrated pending transaction bodies are intentionally
1217    /// excluded until their complete body/response integrity is validated.
1218    pub fn is_payload_deduplicable(&self) -> bool {
1219        matches!(
1220            &self.input,
1221            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
1222        )
1223    }
1224
1225    /// Merge `other` when it is the same safely deduplicable provider object.
1226    ///
1227    /// Returns `Ok(false)` for a different identity or a representation whose
1228    /// complete payload cannot be proven equivalent. A same-identity payload or
1229    /// semantic conflict returns an error. Successful merges are deterministic:
1230    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
1231    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
1232    /// and provenance uses a stable source priority. The result is therefore
1233    /// independent of historical/live arrival order.
1234    ///
1235    /// # Errors
1236    ///
1237    /// Returns [`ReactiveError`] when either record is invalid, or when equal
1238    /// identities carry conflicting payload or semantic context.
1239    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
1240        let identity = self.validated_identity()?;
1241        let other_identity = other.validated_identity()?;
1242        if identity != other_identity
1243            || !self.is_payload_deduplicable()
1244            || !other.is_payload_deduplicable()
1245        {
1246            return Ok(false);
1247        }
1248        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
1249            return Err(ReactiveError::InvalidInputRecord {
1250                message: format!(
1251                    "conflicting payload or semantic context for identity {identity:?}"
1252                ),
1253            });
1254        }
1255        let mut merged = self.clone();
1256        merge_deduplicable_record(&mut merged, other);
1257        merged.validated_identity()?;
1258        *self = merged;
1259        Ok(true)
1260    }
1261
1262    /// Whether semantic context agrees for deduplication across transports.
1263    ///
1264    /// Provenance source and confirmation count may legitimately differ at a
1265    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1266    /// transaction/log positions must agree. Block number/hash are exact;
1267    /// optional parent/timestamp metadata may be enriched by one source but two
1268    /// present conflicting values are rejected.
1269    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1270        let left = &self.context;
1271        let right = &other.context;
1272        left.chain_id == right.chain_id
1273            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1274            && left.transaction_index == right.transaction_index
1275            && left.log_index == right.log_index
1276            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1277    }
1278}
1279
1280fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1281    match (left, right) {
1282        (ChainStatus::Pending, ChainStatus::Pending)
1283        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1284        (
1285            ChainStatus::Preconfirmed { flashblock: left },
1286            ChainStatus::Preconfirmed { flashblock: right },
1287        ) => left == right,
1288        (
1289            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1290            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1291        ) => true,
1292        _ => false,
1293    }
1294}
1295
1296fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1297    left.zip(right).is_none_or(|(left, right)| left == right)
1298}
1299
1300fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1301    match (left, right) {
1302        (None, None) => true,
1303        (Some(left), Some(right)) => {
1304            left.number == right.number
1305                && left.hash == right.hash
1306                && optional_metadata_compatible(
1307                    left.parent_hash.as_ref(),
1308                    right.parent_hash.as_ref(),
1309                )
1310                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1311        }
1312        _ => false,
1313    }
1314}
1315
1316fn merge_deduplicable_record<N: Network>(
1317    retained: &mut ReactiveInputRecord<N>,
1318    incoming: &ReactiveInputRecord<N>,
1319) {
1320    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1321        (&mut retained.input, &incoming.input)
1322        && retained.block_timestamp.is_none()
1323    {
1324        retained.block_timestamp = incoming.block_timestamp;
1325    }
1326    if let (Some(retained), Some(incoming)) =
1327        (&mut retained.context.block, incoming.context.block.as_ref())
1328    {
1329        enrich_block_ref(retained, incoming);
1330    }
1331    retained.context.chain_status = merged_chain_status(
1332        &retained.context.chain_status,
1333        &incoming.context.chain_status,
1334    );
1335    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1336        retained.context.source = incoming.context.source;
1337    }
1338    if retained.provider.is_none() {
1339        retained.provider = incoming.provider.clone();
1340    }
1341}
1342
1343fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1344    if retained.parent_hash.is_none() {
1345        retained.parent_hash = incoming.parent_hash;
1346    }
1347    if retained.timestamp.is_none() {
1348        retained.timestamp = incoming.timestamp;
1349    }
1350}
1351
1352fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1353    let merged_block = |left: &BlockRef, right: &BlockRef| {
1354        let mut block = *left;
1355        enrich_block_ref(&mut block, right);
1356        block
1357    };
1358    match (retained, incoming) {
1359        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1360        (
1361            ChainStatus::Preconfirmed { flashblock: left },
1362            ChainStatus::Preconfirmed { flashblock: right },
1363        ) => {
1364            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1365            ChainStatus::Preconfirmed {
1366                flashblock: left.clone(),
1367            }
1368        }
1369        (
1370            ChainStatus::Reorged { dropped_from: left },
1371            ChainStatus::Reorged {
1372                dropped_from: right,
1373            },
1374        ) => ChainStatus::Reorged {
1375            dropped_from: merged_block(left, right),
1376        },
1377        (left, right) => {
1378            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1379                .expect("compatible duplicate has a canonical lifecycle");
1380            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1381                .expect("compatible duplicate has a canonical lifecycle");
1382            let block = merged_block(left_block, right_block);
1383            let rank = left_rank.max(right_rank);
1384            match rank {
1385                3 => ChainStatus::Finalized { block },
1386                2 => ChainStatus::Safe { block },
1387                _ => ChainStatus::Included {
1388                    block,
1389                    confirmations: left_confirmations.max(right_confirmations),
1390                },
1391            }
1392        }
1393    }
1394}
1395
1396fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1397    match status {
1398        ChainStatus::Included {
1399            block,
1400            confirmations,
1401        } => Some((block, 1, *confirmations)),
1402        ChainStatus::Safe { block } => Some((block, 2, 0)),
1403        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1404        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1405            None
1406        }
1407    }
1408}
1409
1410fn input_source_rank(source: InputSource) -> u8 {
1411    match source {
1412        InputSource::Backfill => 0,
1413        InputSource::Poll => 1,
1414        InputSource::Subscription => 2,
1415        InputSource::Flashblocks => 3,
1416        InputSource::Batch => 4,
1417        InputSource::Synthetic => 5,
1418    }
1419}
1420
1421/// Opaque subscriber-owned token attached to a delivered input batch.
1422///
1423/// Subscribers that provide durable, at-least-once delivery can use this token
1424/// to identify the batch that becomes committable after runtime ingestion
1425/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1426/// stable across replay, and must never identify two different batch payloads.
1427/// Subscriber implementations must preserve delivery order while one token is
1428/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1429/// later batch.
1430#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1431pub struct SubscriberDeliveryToken(Vec<u8>);
1432
1433impl SubscriberDeliveryToken {
1434    /// Create an opaque delivery token from subscriber-owned bytes.
1435    pub fn new(bytes: Vec<u8>) -> Self {
1436        Self(bytes)
1437    }
1438
1439    /// Borrow the opaque token bytes.
1440    pub fn as_bytes(&self) -> &[u8] {
1441        &self.0
1442    }
1443
1444    /// Consume the token into its opaque bytes.
1445    pub fn into_bytes(self) -> Vec<u8> {
1446        self.0
1447    }
1448}
1449
1450/// Opaque source checkpoint associated with a delivered batch.
1451///
1452/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1453/// acknowledge, this value describes provider-specific resume state. The core
1454/// crate persists and returns the bytes without interpreting their format.
1455#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1456pub struct SubscriberCheckpoint(Vec<u8>);
1457
1458impl SubscriberCheckpoint {
1459    /// Create an opaque source checkpoint from subscriber-owned bytes.
1460    pub fn new(bytes: Vec<u8>) -> Self {
1461        Self(bytes)
1462    }
1463
1464    /// Borrow the opaque checkpoint bytes.
1465    pub fn as_bytes(&self) -> &[u8] {
1466        &self.0
1467    }
1468
1469    /// Consume the checkpoint into its opaque bytes.
1470    pub fn into_bytes(self) -> Vec<u8> {
1471        self.0
1472    }
1473}
1474
1475/// Subscriber-supplied commitment to the exact canonical wire payload of one
1476/// delivered batch.
1477///
1478/// The core includes this value in its durable replay witness. It is required
1479/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1480/// network-generic Rust response types cannot be serialized completely by the
1481/// core. The source must recompute the commitment from a stable canonical
1482/// encoding on every replay; reusing a commitment for changed bytes violates the
1483/// [`EventSubscriber`] contract.
1484#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1485pub struct SubscriberPayloadCommitment(B256);
1486
1487impl SubscriberPayloadCommitment {
1488    /// Wrap a cryptographic commitment produced by the subscriber.
1489    pub const fn new(commitment: B256) -> Self {
1490        Self(commitment)
1491    }
1492
1493    /// Return the committed digest.
1494    pub const fn digest(&self) -> B256 {
1495        self.0
1496    }
1497}
1498
1499/// Durable subscriber position restored together with cache/runtime state.
1500///
1501/// The core never interprets provider checkpoint bytes. Composite and remote
1502/// subscribers use this synchronous hand-off to seed their source cursors,
1503/// replay fences, and canonical overlap journals before polling resumes.
1504#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1505#[non_exhaustive]
1506pub struct SubscriberResumePosition {
1507    /// Chain whose canonical position and provider cursor are being restored.
1508    pub chain_id: u64,
1509    /// Authoritative canonical coverage embodied by the restored cache.
1510    pub coverage_head: BlockRef,
1511    /// Ordered canonical identities still retained for in-window reconciliation.
1512    pub canonical_history: Vec<BlockRef>,
1513    /// Last delivery token whose effects are already represented by the cache.
1514    /// It may still be pending at the source when the process stopped after its
1515    /// durable save but before the source acknowledgement committed.
1516    pub delivery_token: Option<SubscriberDeliveryToken>,
1517    /// Provider-specific durable cursor committed with that delivery.
1518    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1519}
1520
1521impl SubscriberResumePosition {
1522    /// Construct a complete restored subscriber position.
1523    pub fn new(
1524        chain_id: u64,
1525        coverage_head: BlockRef,
1526        canonical_history: Vec<BlockRef>,
1527        delivery_token: Option<SubscriberDeliveryToken>,
1528        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1529    ) -> Self {
1530        Self {
1531            chain_id,
1532            coverage_head,
1533            canonical_history,
1534            delivery_token,
1535            subscriber_checkpoint,
1536        }
1537    }
1538}
1539
1540/// Runtime routing audience for one delivered subscriber batch.
1541///
1542/// Historical catch-up for a newly registered handler must not be routed
1543/// through older handlers whose filters happen to overlap. Subscribers retain
1544/// that provenance by targeting the batch at the exact logical owners that
1545/// requested it. Ordinary canonical delivery remains broadcast to every
1546/// matching handler.
1547#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1548#[non_exhaustive]
1549pub enum DeliveryAudience {
1550    /// Route each record through every matching registered handler.
1551    #[default]
1552    All,
1553    /// Route each record only through the named matching handlers.
1554    Owners(Vec<HandlerId>),
1555    /// Route through every matching handler except the named owners.
1556    ///
1557    /// Composite subscribers use this to deliver the residual audience after an
1558    /// overlapping source already committed the same input for selected owners.
1559    AllExcept(Vec<HandlerId>),
1560}
1561
1562/// How one delivered record participates in the runtime's canonical state machine.
1563///
1564/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1565/// selects handlers, while this value decides whether a record may advance or
1566/// rewind global chain state. Historical replay for a newly added owner must use
1567/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1568/// is canonical.
1569#[derive(
1570    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1571)]
1572#[non_exhaustive]
1573pub enum DeliveryScope {
1574    /// Authoritative live canonical delivery.
1575    #[default]
1576    Canonical,
1577    /// Authoritative historical/recovery delivery that advances canonical progress.
1578    CanonicalProgress,
1579    /// Historical replay routed to selected owners without changing global chain state.
1580    OwnerCatchup,
1581    /// Ephemeral pre-confirmation delivery applied only to the speculative
1582    /// cache overlay.
1583    Preconfirmed,
1584}
1585
1586impl DeliveryScope {
1587    const fn advances_canonical_state(self) -> bool {
1588        matches!(self, Self::Canonical | Self::CanonicalProgress)
1589    }
1590}
1591
1592/// One input together with its routing and canonical-processing provenance.
1593#[derive(Clone, Debug)]
1594pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1595    record: ReactiveInputRecord<N>,
1596    audience: DeliveryAudience,
1597    scope: DeliveryScope,
1598}
1599
1600impl<N: Network> ReactiveInputDelivery<N> {
1601    /// Construct one lossless delivered record.
1602    pub fn new(
1603        record: ReactiveInputRecord<N>,
1604        audience: DeliveryAudience,
1605        scope: DeliveryScope,
1606    ) -> Self {
1607        Self {
1608            record,
1609            audience,
1610            scope,
1611        }
1612    }
1613
1614    /// Borrow the runtime input record.
1615    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1616        &self.record
1617    }
1618
1619    /// Borrow the exact routing audience.
1620    pub const fn audience(&self) -> &DeliveryAudience {
1621        &self.audience
1622    }
1623
1624    /// Return the record's canonical-processing scope.
1625    pub const fn scope(&self) -> DeliveryScope {
1626        self.scope
1627    }
1628
1629    /// Consume this value into its complete parts.
1630    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1631        (self.record, self.audience, self.scope)
1632    }
1633}
1634
1635/// Complete contents of a consumed [`ReactiveInputBatch`].
1636///
1637/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1638/// discards subscriber commit and chain-lifecycle metadata.
1639#[derive(Clone, Debug)]
1640#[non_exhaustive]
1641pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1642    /// Authoritative chain identity for controls and records in this batch.
1643    pub chain_id: Option<u64>,
1644    /// Records with per-record routing and chain provenance.
1645    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1646    /// Subscriber delivery token committed after ingestion.
1647    pub delivery_token: Option<SubscriberDeliveryToken>,
1648    /// Provider-specific resume cursor associated with the delivery.
1649    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1650    /// Exact opaque wire-payload commitment supplied by the subscriber.
1651    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1652    /// Ordered chain controls sharing the delivery's commit boundary.
1653    pub chain_controls: Vec<ChainControl>,
1654}
1655
1656/// Batch of reactive input records.
1657#[derive(Clone, Debug)]
1658pub struct ReactiveInputBatch<N: Network = Ethereum> {
1659    records: Vec<ReactiveInputRecord<N>>,
1660    chain_id: Option<u64>,
1661    delivery_token: Option<SubscriberDeliveryToken>,
1662    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1663    payload_commitment: Option<SubscriberPayloadCommitment>,
1664    audience: DeliveryAudience,
1665    record_audiences: Option<Vec<DeliveryAudience>>,
1666    delivery_scope: DeliveryScope,
1667    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1668    chain_controls: Vec<ChainControl>,
1669}
1670
1671type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1672
1673impl<N: Network> ReactiveInputBatch<N> {
1674    /// Create a batch from records.
1675    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1676        let chain_id = common_record_chain_id(&records);
1677        Self {
1678            records,
1679            chain_id,
1680            delivery_token: None,
1681            subscriber_checkpoint: None,
1682            payload_commitment: None,
1683            audience: DeliveryAudience::All,
1684            record_audiences: None,
1685            delivery_scope: DeliveryScope::Canonical,
1686            record_delivery_scopes: None,
1687            chain_controls: Vec::new(),
1688        }
1689    }
1690
1691    /// Bind the complete batch, including control-only progress/finality, to a
1692    /// chain. Runtime ingestion rejects a different cache chain.
1693    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1694        self.chain_id = Some(chain_id);
1695        self
1696    }
1697
1698    /// Authoritative batch chain identity, when supplied or unambiguously
1699    /// derived from its records.
1700    pub const fn chain_id(&self) -> Option<u64> {
1701        self.chain_id
1702    }
1703
1704    /// Attach the subscriber-owned token committed after successful ingestion.
1705    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1706        self.delivery_token = Some(token);
1707        self
1708    }
1709
1710    /// Borrow the subscriber-owned delivery token, when present.
1711    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1712        self.delivery_token.as_ref()
1713    }
1714
1715    /// Attach provider-specific resume state included by this delivery.
1716    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1717        self.subscriber_checkpoint = Some(checkpoint);
1718        self
1719    }
1720
1721    /// Borrow provider-specific resume state, when present.
1722    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1723        self.subscriber_checkpoint.as_ref()
1724    }
1725
1726    /// Attach a commitment to the exact canonical wire payload represented by
1727    /// this batch.
1728    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1729        self.payload_commitment = Some(commitment);
1730        self
1731    }
1732
1733    /// Borrow the subscriber-supplied exact payload commitment, when present.
1734    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1735        self.payload_commitment.as_ref()
1736    }
1737
1738    /// Restrict runtime routing to exact logical interest owners.
1739    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1740        self.audience = audience;
1741        self.record_audiences = None;
1742        self
1743    }
1744
1745    /// Delivery audience captured by the subscriber.
1746    pub const fn audience(&self) -> &DeliveryAudience {
1747        &self.audience
1748    }
1749
1750    /// Create a batch whose records retain independent delivery audiences.
1751    pub fn from_scoped_records(
1752        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1753    ) -> Self {
1754        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1755        let chain_id = common_record_chain_id(&records);
1756        Self {
1757            records,
1758            chain_id,
1759            delivery_token: None,
1760            subscriber_checkpoint: None,
1761            payload_commitment: None,
1762            audience: DeliveryAudience::All,
1763            record_audiences: Some(record_audiences),
1764            delivery_scope: DeliveryScope::Canonical,
1765            record_delivery_scopes: None,
1766            chain_controls: Vec::new(),
1767        }
1768    }
1769
1770    /// Create a batch with independent routing and canonical provenance per record.
1771    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1772        Self::from_scoped_records_with_delivery_scope(
1773            deliveries
1774                .into_iter()
1775                .map(ReactiveInputDelivery::into_parts),
1776        )
1777    }
1778
1779    /// Audience for the record at `index`.
1780    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1781        if index >= self.records.len() {
1782            return None;
1783        }
1784        Some(
1785            self.record_audiences
1786                .as_ref()
1787                .and_then(|audiences| audiences.get(index))
1788                .unwrap_or(&self.audience),
1789        )
1790    }
1791
1792    /// Set how every record in this batch participates in canonical state.
1793    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1794        self.delivery_scope = scope;
1795        self.record_delivery_scopes = None;
1796        self
1797    }
1798
1799    /// Canonical-processing scope for the record at `index`.
1800    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
1801        if index >= self.records.len() {
1802            return None;
1803        }
1804        Some(
1805            self.record_delivery_scopes
1806                .as_ref()
1807                .and_then(|scopes| scopes.get(index))
1808                .copied()
1809                .unwrap_or(self.delivery_scope),
1810        )
1811    }
1812
1813    fn from_scoped_records_with_delivery_scope(
1814        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
1815    ) -> Self {
1816        let mut input_records = Vec::new();
1817        let mut audiences = Vec::new();
1818        let mut scopes = Vec::new();
1819        for (record, audience, scope) in records {
1820            input_records.push(record);
1821            audiences.push(audience);
1822            scopes.push(scope);
1823        }
1824        let chain_id = common_record_chain_id(&input_records);
1825        Self {
1826            records: input_records,
1827            chain_id,
1828            delivery_token: None,
1829            subscriber_checkpoint: None,
1830            payload_commitment: None,
1831            audience: DeliveryAudience::All,
1832            record_audiences: Some(audiences),
1833            delivery_scope: DeliveryScope::Canonical,
1834            record_delivery_scopes: Some(scopes),
1835            chain_controls: Vec::new(),
1836        }
1837    }
1838
1839    /// Attach ordered chain-lifecycle controls to this delivery.
1840    ///
1841    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
1842    /// When records are present, their unanimous chain id is derived by the
1843    /// constructor; a missing or cache-mismatched authoritative batch identity
1844    /// is rejected before any control mutates runtime state.
1845    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
1846        self.chain_controls = controls.into_iter().collect();
1847        self
1848    }
1849
1850    /// Ordered chain-lifecycle controls in this delivery.
1851    pub fn chain_controls(&self) -> &[ChainControl] {
1852        &self.chain_controls
1853    }
1854
1855    /// Borrow the records in this batch.
1856    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
1857        &self.records
1858    }
1859
1860    /// Consume the batch into only its input records.
1861    ///
1862    /// This is intentionally lossy: it discards the authoritative batch chain
1863    /// identity, routing audiences, delivery scopes, ordered chain controls,
1864    /// acknowledgement tokens, and provider checkpoints. Adapters should use
1865    /// [`into_parts`](Self::into_parts) instead.
1866    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
1867        self.records
1868    }
1869
1870    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
1871    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
1872        let chain_id = self.chain_id;
1873        let delivery_token = self.delivery_token;
1874        let subscriber_checkpoint = self.subscriber_checkpoint;
1875        let payload_commitment = self.payload_commitment;
1876        let chain_controls = self.chain_controls;
1877        let audiences = self
1878            .record_audiences
1879            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
1880        let scopes = self
1881            .record_delivery_scopes
1882            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
1883        let deliveries = self
1884            .records
1885            .into_iter()
1886            .zip(audiences)
1887            .zip(scopes)
1888            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
1889            .collect();
1890        ReactiveInputBatchParts {
1891            chain_id,
1892            deliveries,
1893            delivery_token,
1894            subscriber_checkpoint,
1895            payload_commitment,
1896            chain_controls,
1897        }
1898    }
1899
1900    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
1901        let audiences = self
1902            .record_audiences
1903            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
1904        let scopes = self
1905            .record_delivery_scopes
1906            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
1907        let records = self
1908            .records
1909            .into_iter()
1910            .zip(audiences)
1911            .zip(scopes)
1912            .map(|((record, audience), scope)| (record, audience, scope))
1913            .collect();
1914        (records, self.chain_controls, self.chain_id)
1915    }
1916
1917    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
1918        self.delivery_token.take()
1919    }
1920
1921    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
1922        self.subscriber_checkpoint.take()
1923    }
1924}
1925
1926fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
1927    let chain_id = records.first()?.context.chain_id?;
1928    records
1929        .iter()
1930        .all(|record| record.context.chain_id == Some(chain_id))
1931        .then_some(chain_id)
1932}
1933
1934/// Pure synchronous handler for reactive inputs.
1935pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
1936    /// Stable handler id.
1937    fn id(&self) -> HandlerId;
1938
1939    /// Interests used by subscribers and the local router.
1940    fn interests(&self) -> Vec<ReactiveInterest<N>>;
1941
1942    /// Exhaustive exact keys for log inputs this handler can accept.
1943    ///
1944    /// Returning `None` keeps the handler on the compatibility fallback path.
1945    /// Returning an index promises that every matching log has at least one of
1946    /// its keys; the registry still re-checks the handler's original
1947    /// [`LogInterest`]s and local matchers before dispatch.
1948    fn log_route_index(&self) -> Option<LogRouteIndex> {
1949        None
1950    }
1951
1952    /// Handle one input against a read-only cache view.
1953    fn handle(
1954        &self,
1955        ctx: &ReactiveContext,
1956        input: &ReactiveInput<N>,
1957        state: &dyn StateView,
1958    ) -> Result<HandlerOutcome, HandlerError>;
1959}
1960
1961/// Hook invoked after reports are built and cache mutation phases have ended.
1962///
1963/// Hooks are synchronous in-process observers, not a durable transactional
1964/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
1965/// back during checkpoint staging, and it dispatches a successfully staged
1966/// batch at most once per live engine. A process crash can still occur between
1967/// hook dispatch and durable checkpoint or transport acknowledgement. External
1968/// side effects therefore need their own idempotency key (normally an
1969/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
1970pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
1971    /// Observe a runtime report.
1972    fn on_report(&self, report: Arc<ReactiveReport<N>>);
1973}
1974
1975/// Reactive subscription interest.
1976#[allow(clippy::large_enum_variant)]
1977#[derive(Clone)]
1978pub enum ReactiveInterest<N: Network = Ethereum> {
1979    /// Log interest.
1980    Logs(LogInterest),
1981    /// Block interest.
1982    Blocks(BlockInterest),
1983    /// Pending transaction interest.
1984    PendingTransactions(PendingTxInterest<N>),
1985}
1986
1987impl<N: Network> fmt::Debug for ReactiveInterest<N> {
1988    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1989        match self {
1990            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
1991            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
1992            Self::PendingTransactions(interest) => f
1993                .debug_tuple("PendingTransactions")
1994                .field(interest)
1995                .finish(),
1996        }
1997    }
1998}
1999
2000/// Interest in logs.
2001#[derive(Clone)]
2002pub struct LogInterest {
2003    /// Provider-side filter.
2004    pub provider_filter: Filter,
2005    /// Optional local matcher for predicates providers cannot express.
2006    pub local_matcher: Option<Arc<dyn LogMatcher>>,
2007    /// Optional route-key extraction strategy.
2008    pub route_key: Option<RouteKeySpec>,
2009}
2010
2011impl LogInterest {
2012    /// Return true if the log matches both the provider filter and local matcher.
2013    pub fn matches(&self, log: &Log) -> bool {
2014        self.provider_filter.rpc_matches(log)
2015            && self
2016                .local_matcher
2017                .as_ref()
2018                .is_none_or(|matcher| matcher.matches(log))
2019    }
2020
2021    /// Extract the route key for a matching log, if configured.
2022    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
2023        self.route_key.as_ref().and_then(|spec| spec.extract(log))
2024    }
2025}
2026
2027impl fmt::Debug for LogInterest {
2028    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2029        f.debug_struct("LogInterest")
2030            .field("provider_filter", &self.provider_filter)
2031            .field(
2032                "local_matcher",
2033                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2034            )
2035            .field("route_key", &self.route_key)
2036            .finish()
2037    }
2038}
2039
2040/// Local log predicate.
2041pub trait LogMatcher: Send + Sync {
2042    /// Return true when the log should be routed to the handler.
2043    fn matches(&self, log: &Log) -> bool;
2044}
2045
2046/// Route-key extraction strategy for logs.
2047#[derive(Clone)]
2048pub enum RouteKeySpec {
2049    /// Route by emitting address.
2050    EmitterAddress,
2051    /// Route by indexed topic.
2052    Topic {
2053        /// Topic index.
2054        index: usize,
2055    },
2056    /// Route by a byte slice in log data.
2057    DataSlice {
2058        /// Byte offset in the data payload.
2059        offset: usize,
2060        /// Number of bytes to copy.
2061        len: usize,
2062    },
2063    /// Custom extractor.
2064    Custom(Arc<dyn RouteKeyExtractor>),
2065}
2066
2067impl RouteKeySpec {
2068    /// Extract a route key from a log.
2069    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
2070        match self {
2071            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
2072            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
2073            Self::DataSlice { offset, len } => {
2074                let data = log.inner.data.data.as_ref();
2075                let end = offset.checked_add(*len)?;
2076                data.get(*offset..end)
2077                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
2078            }
2079            Self::Custom(extractor) => extractor.extract(log),
2080        }
2081    }
2082}
2083
2084impl fmt::Debug for RouteKeySpec {
2085    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2086        match self {
2087            Self::EmitterAddress => f.write_str("EmitterAddress"),
2088            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
2089            Self::DataSlice { offset, len } => f
2090                .debug_struct("DataSlice")
2091                .field("offset", offset)
2092                .field("len", len)
2093                .finish(),
2094            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
2095        }
2096    }
2097}
2098
2099/// Extracts custom route keys from logs.
2100pub trait RouteKeyExtractor: Send + Sync {
2101    /// Extract a route key.
2102    fn extract(&self, log: &Log) -> Option<RouteKey>;
2103}
2104
2105/// Extracted route key.
2106#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2107pub enum RouteKey {
2108    /// Address key.
2109    Address(Address),
2110    /// 32-byte key.
2111    Bytes32(B256),
2112    /// Arbitrary bytes key.
2113    Bytes(Vec<u8>),
2114}
2115
2116/// Exact protocol-neutral key used to select candidate log handlers.
2117#[non_exhaustive]
2118#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2119pub enum LogRouteKey {
2120    /// Emitting contract address.
2121    Emitter(Address),
2122    /// Exact indexed topic.
2123    Topic {
2124        /// Topic position in the log.
2125        index: usize,
2126        /// Expected topic value.
2127        value: B256,
2128    },
2129    /// Exact byte slice in the log data.
2130    DataSlice {
2131        /// Byte offset in the data payload.
2132        offset: usize,
2133        /// Expected bytes.
2134        value: Vec<u8>,
2135    },
2136}
2137
2138/// Non-empty exhaustive OR-set of exact log route keys.
2139#[derive(Clone, Debug, PartialEq, Eq)]
2140pub struct LogRouteIndex {
2141    keys: Vec<LogRouteKey>,
2142}
2143
2144impl LogRouteIndex {
2145    /// Construct an index from one required key and optional additional keys.
2146    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
2147        let mut keys = vec![primary];
2148        for key in additional {
2149            if !keys.contains(&key) {
2150                keys.push(key);
2151            }
2152        }
2153        Self { keys }
2154    }
2155
2156    /// Construct a single-key index.
2157    pub fn single(key: LogRouteKey) -> Self {
2158        Self { keys: vec![key] }
2159    }
2160
2161    /// Exact keys in declaration order.
2162    pub fn keys(&self) -> &[LogRouteKey] {
2163        &self.keys
2164    }
2165}
2166
2167/// Exact log route selected by [`ReactiveRegistry::route_log`].
2168#[derive(Clone, Debug, PartialEq, Eq)]
2169pub struct ReactiveLogRoute {
2170    /// Handler whose log interest matched.
2171    pub handler_id: HandlerId,
2172    /// Optional route key extracted from the matching log interest.
2173    pub route_key: Option<RouteKey>,
2174}
2175
2176/// Interest in block inputs.
2177#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2178pub struct BlockInterest {
2179    /// Block input mode.
2180    pub mode: BlockInterestMode,
2181}
2182
2183impl Default for BlockInterest {
2184    fn default() -> Self {
2185        Self {
2186            mode: BlockInterestMode::Header,
2187        }
2188    }
2189}
2190
2191/// Block subscription mode.
2192#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2193pub enum BlockInterestMode {
2194    /// Header-only block input.
2195    Header,
2196    /// Full block input.
2197    FullBlock,
2198}
2199
2200/// Interest in pending transaction inputs.
2201#[derive(Clone)]
2202pub struct PendingTxInterest<N: Network = Ethereum> {
2203    /// Whether the handler requires full transaction bodies.
2204    pub full_transactions: bool,
2205    /// Sender matcher.
2206    pub from: AddressMatcher,
2207    /// Recipient matcher.
2208    pub to: AddressMatcher,
2209    /// Calldata selector matcher.
2210    pub selectors: SelectorMatcher,
2211    /// Optional local transaction matcher.
2212    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
2213}
2214
2215impl<N: Network> Default for PendingTxInterest<N> {
2216    fn default() -> Self {
2217        Self {
2218            full_transactions: false,
2219            from: AddressMatcher::Any,
2220            to: AddressMatcher::Any,
2221            selectors: SelectorMatcher::Any,
2222            local_matcher: None,
2223        }
2224    }
2225}
2226
2227impl<N: Network> PendingTxInterest<N> {
2228    fn matches_hash_only(&self) -> bool {
2229        !self.full_transactions
2230            && self.from.is_any()
2231            && self.to.is_any()
2232            && self.selectors.is_any()
2233            && self.local_matcher.is_none()
2234    }
2235
2236    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
2237        self.from.matches(tx.from())
2238            && self.to.matches_option(tx.to())
2239            && self.selectors.matches(tx.input())
2240            && self
2241                .local_matcher
2242                .as_ref()
2243                .is_none_or(|matcher| matcher.matches(tx))
2244    }
2245}
2246
2247impl<N: Network> fmt::Debug for PendingTxInterest<N> {
2248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2249        f.debug_struct("PendingTxInterest")
2250            .field("full_transactions", &self.full_transactions)
2251            .field("from", &self.from)
2252            .field("to", &self.to)
2253            .field("selectors", &self.selectors)
2254            .field(
2255                "local_matcher",
2256                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2257            )
2258            .finish()
2259    }
2260}
2261
2262/// Address matching helper for pending transaction interests.
2263#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2264pub enum AddressMatcher {
2265    /// Match every address.
2266    Any,
2267    /// Match one address.
2268    Exact(Address),
2269    /// Match any address in the list.
2270    AnyOf(Vec<Address>),
2271}
2272
2273impl AddressMatcher {
2274    /// Return true when the matcher is unconstrained.
2275    pub fn is_any(&self) -> bool {
2276        matches!(self, Self::Any)
2277    }
2278
2279    /// Match a present address.
2280    pub fn matches(&self, address: Address) -> bool {
2281        match self {
2282            Self::Any => true,
2283            Self::Exact(expected) => *expected == address,
2284            Self::AnyOf(addresses) => addresses.contains(&address),
2285        }
2286    }
2287
2288    /// Match an optional address.
2289    pub fn matches_option(&self, address: Option<Address>) -> bool {
2290        match (self, address) {
2291            (Self::Any, _) => true,
2292            (_, Some(address)) => self.matches(address),
2293            _ => false,
2294        }
2295    }
2296}
2297
2298/// Calldata selector matching helper.
2299#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2300pub enum SelectorMatcher {
2301    /// Match every selector.
2302    Any,
2303    /// Match any selector in the list.
2304    AnyOf(Vec<[u8; 4]>),
2305}
2306
2307impl SelectorMatcher {
2308    /// Return true when the matcher is unconstrained.
2309    pub fn is_any(&self) -> bool {
2310        matches!(self, Self::Any)
2311    }
2312
2313    /// Match calldata bytes.
2314    pub fn matches(&self, input: &Bytes) -> bool {
2315        match self {
2316            Self::Any => true,
2317            Self::AnyOf(selectors) => input
2318                .get(..4)
2319                .and_then(|bytes| bytes.try_into().ok())
2320                .is_some_and(|selector| selectors.contains(&selector)),
2321        }
2322    }
2323}
2324
2325/// Local predicate over a full pending transaction.
2326pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2327    /// Return true when the transaction should be routed to the handler.
2328    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2329}
2330
2331/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2332///
2333/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2334/// liveness strategy is per-contract:
2335///
2336/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2337///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2338///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2339/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2340///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2341///   each canonical block; a move a decoder did not cover is a coverage gap.
2342///
2343/// A false-positive resync is never *incorrect* — it costs one batched read — so
2344/// the policy is a **pure cost knob**, not a correctness lever.
2345///
2346/// [`Slots`]: TrackingPolicy::Slots
2347/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2348#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2349#[non_exhaustive]
2350pub enum TrackingPolicy {
2351    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2352    /// nearly every block, so it is a noisy gate — this policy is **never**
2353    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2354    /// and cadence reconcile.
2355    Slots {
2356        /// The enumerated storage slots of interest.
2357        slots: Vec<U256>,
2358    },
2359    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2360    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2361    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2362    /// [`ResyncReason::RootMoved`] repair.
2363    WholeAccount,
2364    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2365    /// response's account fields; no storage interest. Native balance/nonce
2366    /// changes do **not** move the storage root, so this policy compares the
2367    /// account fields directly across blocks rather than root-gating.
2368    Scalars,
2369}
2370
2371/// How often the reactive root gate probes tracked accounts
2372/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2373/// `Scalars` account-fields comparison rides the same firing).
2374///
2375/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2376/// is never the default. Skipping blocks is safe by construction: the gate
2377/// diffs `root_now` against its **persisted baseline**, never
2378/// block-over-block, so a move in any skipped block is still visible at the
2379/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2380/// cost, never eventual detection. The decoder-touched set accumulates across
2381/// skipped blocks and drains per firing, so a covered write in a skipped
2382/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2383#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2384pub enum RootGateCadence {
2385    /// Probe at most once every `n` canonical blocks (the first canonical
2386    /// block ever seen always fires, so baseline adoption does not wait a
2387    /// full window). `EveryNBlocks(1)` is per-block probing.
2388    EveryNBlocks(NonZeroU64),
2389    /// Root gate off: coverage gaps surface only via decoders + freshness.
2390    Disabled,
2391}
2392
2393impl RootGateCadence {
2394    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2395    pub fn every_n_blocks(n: u64) -> Self {
2396        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2397    }
2398}
2399
2400impl Default for RootGateCadence {
2401    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2402    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2403    /// `n`, not lower it.
2404    fn default() -> Self {
2405        Self::every_n_blocks(16)
2406    }
2407}
2408
2409/// Per-account baseline held by the root gate: the last observed on-chain root
2410/// and account fields, plus the block they were observed at.
2411///
2412/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2413/// spec §6): it persists the *observed* root as a baseline and compares
2414/// `root_now` to it. This is a currency gate, not a completeness gate.
2415#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2416struct TrackedRoot {
2417    last_root: B256,
2418    last_block: u64,
2419    balance: U256,
2420    nonce: u64,
2421    code_hash: B256,
2422}
2423
2424/// Request for authoritative state repair.
2425#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2426pub struct ResyncRequest {
2427    /// Resync id.
2428    pub id: ResyncId,
2429    /// Reason for the request.
2430    pub reason: ResyncReason,
2431    /// Block selection for the read.
2432    pub block: ResyncBlock,
2433    /// Targets to resync.
2434    pub targets: Vec<ResyncTarget>,
2435    /// Scheduling priority.
2436    pub priority: ResyncPriority,
2437}
2438
2439/// Resync id.
2440#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2441pub struct ResyncId(String);
2442
2443impl ResyncId {
2444    /// Create a resync id.
2445    pub fn new(id: impl Into<String>) -> Self {
2446        Self(id.into())
2447    }
2448}
2449
2450/// Reason for a resync request.
2451#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2452#[non_exhaustive]
2453pub enum ResyncReason {
2454    /// Handler requested repair.
2455    HandlerRequested,
2456    /// State effect could not be applied completely.
2457    SkippedStateEffect,
2458    /// A missed block range was detected; caller-scheduled repair.
2459    ///
2460    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2461    /// range (there are no known targets to resync). This reason is provided so a
2462    /// caller building its own repair in response to a
2463    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2464    MissedBlockRange,
2465    /// A tracked account's storage root moved with no covering decoder.
2466    ///
2467    /// Emitted by the per-block root gate (Phase-8 step 4). A
2468    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2469    /// `storageHash` moved between the adopted baseline and the current canonical
2470    /// block, yet no decoder wrote that account during the block — a coverage gap.
2471    /// The gate schedules a resync with this reason to re-read the account
2472    /// authoritatively and self-heal the blind spot. Also used for the
2473    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2474    RootMoved,
2475    /// Caller-defined reason.
2476    Custom(String),
2477}
2478
2479/// Block target for a resync.
2480#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2481pub enum ResyncBlock {
2482    /// Latest block.
2483    Latest,
2484    /// Current provider pre-confirmation state.
2485    Pending,
2486    /// Safe head.
2487    Safe,
2488    /// Finalized head.
2489    Finalized,
2490    /// Block number.
2491    Number(u64),
2492    /// Block hash and number.
2493    Hash {
2494        /// Block number.
2495        number: u64,
2496        /// Block hash.
2497        hash: B256,
2498        /// Require the hash to still be canonical.
2499        require_canonical: bool,
2500    },
2501}
2502
2503/// State target for a resync.
2504#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2505pub enum ResyncTarget {
2506    /// One storage slot.
2507    StorageSlot {
2508        /// Contract address.
2509        address: Address,
2510        /// Storage slot.
2511        slot: U256,
2512    },
2513    /// Multiple storage slots on one contract.
2514    StorageSlots {
2515        /// Contract address.
2516        address: Address,
2517        /// Storage slots.
2518        slots: Vec<U256>,
2519    },
2520    /// Account fields.
2521    Account {
2522        /// Account address.
2523        address: Address,
2524        /// Fields to resync.
2525        fields: AccountFieldMask,
2526    },
2527}
2528
2529/// Account fields requested by a resync.
2530#[derive(
2531    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2532)]
2533pub struct AccountFieldMask {
2534    /// Balance field.
2535    pub balance: bool,
2536    /// Nonce field.
2537    pub nonce: bool,
2538    /// Code field.
2539    pub code: bool,
2540}
2541
2542/// Resync priority.
2543#[derive(
2544    Clone,
2545    Copy,
2546    Debug,
2547    Default,
2548    PartialEq,
2549    Eq,
2550    Hash,
2551    PartialOrd,
2552    Ord,
2553    serde::Serialize,
2554    serde::Deserialize,
2555)]
2556pub enum ResyncPriority {
2557    /// Low priority.
2558    Low,
2559    /// Normal priority.
2560    #[default]
2561    Normal,
2562    /// High priority.
2563    High,
2564}
2565
2566/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2567#[derive(Clone, Debug, PartialEq, Eq)]
2568pub struct InvalidationRequest {
2569    /// Purge scope.
2570    pub scope: PurgeScope,
2571    /// Address to purge.
2572    pub address: Address,
2573    /// Reason for reporting.
2574    pub reason: InvalidationReason,
2575}
2576
2577/// Invalidation reason.
2578#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2579pub enum InvalidationReason {
2580    /// Handler requested invalidation.
2581    HandlerRequested,
2582    /// Reorg invalidation.
2583    Reorg,
2584    /// Caller-defined reason.
2585    Custom(String),
2586}
2587
2588/// Speculative signal emitted by handlers.
2589#[derive(Clone, Debug, PartialEq, Eq)]
2590pub struct SpeculativeRequest {
2591    /// Speculative request id.
2592    pub id: SpeculativeId,
2593    /// Input that triggered the request.
2594    pub input_ref: InputRef,
2595    /// Labels for downstream routing.
2596    pub labels: Vec<ReportTag>,
2597}
2598
2599/// Speculative request id.
2600#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2601pub struct SpeculativeId(String);
2602
2603impl SpeculativeId {
2604    /// Create a speculative id.
2605    pub fn new(id: impl Into<String>) -> Self {
2606        Self(id.into())
2607    }
2608}
2609
2610/// Configuration for [`ReactiveRuntime`].
2611#[derive(Clone, Debug, PartialEq, Eq)]
2612pub struct ReactiveConfig {
2613    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2614    /// dispatch is synchronous today (every report is delivered to every hook in
2615    /// order), so this field is a no-op placeholder for a future async dispatcher.
2616    /// Setting it to anything other than the default does not change behavior.
2617    pub hook_backpressure: HookBackpressure,
2618    /// Reorg journal depth: the number of recent canonical blocks whose effects
2619    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2620    /// only blocks still resident in the journal can be recovered. A reorg deeper
2621    /// than `journal_depth` recovers the blocks still in the journal and leaves
2622    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2623    /// purged**, so the freshness/validation loop is the only backstop for that
2624    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2625    ///
2626    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2627    /// precisely. When a reorg references a block that is no longer in the journal,
2628    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2629    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2630    /// reorgs, implicit parent replacements, and removed/reorged records whose
2631    /// rollback proof falls outside the retained effect journal are rejected
2632    /// before mutation, durable save, or acknowledgement. Align this depth with
2633    /// the complete reorg horizon promised by the subscriber.
2634    pub journal_depth: usize,
2635}
2636
2637impl Default for ReactiveConfig {
2638    fn default() -> Self {
2639        Self {
2640            hook_backpressure: HookBackpressure::Block,
2641            journal_depth: 64,
2642        }
2643    }
2644}
2645
2646/// Hook backpressure policy.
2647#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2648pub enum HookBackpressure {
2649    /// Block the producer until hooks are accepted.
2650    Block,
2651    /// Drop the newest report under pressure.
2652    DropNewest,
2653    /// Drop the oldest report under pressure.
2654    DropOldest,
2655    /// Return an error under pressure.
2656    Error,
2657}
2658
2659/// Queryable coarse health of the reactive cache.
2660///
2661/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2662/// degraded or unhealthy state when it detects that its recovery guarantees no
2663/// longer hold (for example a reorg that runs deeper than the journal, so some
2664/// dropped effects are neither rolled back nor purged). Later waves report
2665/// missed-range and coverage-gap conditions into the same state machine.
2666#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2667#[non_exhaustive]
2668pub enum CacheHealth {
2669    /// All recovery guarantees hold; the cache is fully self-consistent.
2670    #[default]
2671    Healthy,
2672    /// A recoverable inconsistency was detected (for example under-recovered
2673    /// reorg effects); `since_block` records the block that triggered the
2674    /// transition.
2675    Degraded {
2676        /// Block number at which the degradation was first observed.
2677        since_block: u64,
2678    },
2679    /// A more serious inconsistency was detected; `since_block` records the
2680    /// block that triggered the transition.
2681    Unhealthy {
2682        /// Block number at which the unhealthy condition was first observed.
2683        since_block: u64,
2684    },
2685}
2686
2687/// Point-in-time copy of the reactive runtime's observability counters.
2688///
2689/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2690/// increasing count over the lifetime of the runtime. Counters wired by later
2691/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2692/// tracking) remain zero until those waves land.
2693#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2694#[non_exhaustive]
2695pub struct CacheMetricsSnapshot {
2696    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2697    /// rolled back or purged.
2698    pub deep_reorgs: u64,
2699    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2700    pub reorgs_recovered: u64,
2701    /// Storage resync targets considered by the resync execution pass.
2702    pub resync_requests: u64,
2703    /// Storage resync targets that could not be fetched or applied.
2704    pub resync_failures: u64,
2705    /// Ranges of blocks the runtime detected it did not observe (reserved).
2706    pub missed_ranges: u64,
2707    /// Storage-hash coverage gaps detected (reserved).
2708    pub coverage_gaps: u64,
2709    /// Pending-source inputs that attempted a canonical cache effect.
2710    pub pending_contamination: u64,
2711    /// Verdicts served past their freshness horizon (reserved).
2712    pub stale_verdicts: u64,
2713}
2714
2715/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2716///
2717/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2718/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2719/// into a plain [`CacheMetricsSnapshot`].
2720#[derive(Debug, Default)]
2721struct CacheMetrics {
2722    deep_reorgs: AtomicU64,
2723    reorgs_recovered: AtomicU64,
2724    resync_requests: AtomicU64,
2725    resync_failures: AtomicU64,
2726    missed_ranges: AtomicU64,
2727    coverage_gaps: AtomicU64,
2728    pending_contamination: AtomicU64,
2729    stale_verdicts: AtomicU64,
2730}
2731
2732impl CacheMetrics {
2733    fn snapshot(&self) -> CacheMetricsSnapshot {
2734        CacheMetricsSnapshot {
2735            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2736            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2737            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2738            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2739            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2740            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2741            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2742            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2743        }
2744    }
2745
2746    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2747        self.deep_reorgs
2748            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2749        self.reorgs_recovered
2750            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2751        self.resync_requests
2752            .store(snapshot.resync_requests, Ordering::Relaxed);
2753        self.resync_failures
2754            .store(snapshot.resync_failures, Ordering::Relaxed);
2755        self.missed_ranges
2756            .store(snapshot.missed_ranges, Ordering::Relaxed);
2757        self.coverage_gaps
2758            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2759        self.pending_contamination
2760            .store(snapshot.pending_contamination, Ordering::Relaxed);
2761        self.stale_verdicts
2762            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2763    }
2764}
2765
2766/// Runtime report.
2767#[derive(Clone, Debug)]
2768#[non_exhaustive]
2769pub enum ReactiveReport<N: Network = Ethereum> {
2770    /// Input was accepted after deduplication.
2771    Input(InputReport<N>),
2772    /// Handlers produced outcomes.
2773    Decoded(DecodedReport<N>),
2774    /// Direct state effects were applied.
2775    Applied(AppliedReport<N>),
2776    /// Resync request was scheduled or completed.
2777    Resynced(ResyncReport),
2778    /// Block-level processing completed.
2779    BlockCommitted(BlockReport<N>),
2780    /// Reorg processing report.
2781    Reorg(ReorgReport<N>),
2782    /// Ordered source control accepted by the runtime.
2783    ChainControl(ChainControlReport),
2784    /// A forward gap in the canonical block sequence was detected: blocks between
2785    /// the last-seen head and an arriving block were never observed.
2786    MissedBlockRange(MissedRangeReport<N>),
2787    /// Cache health transitioned between states.
2788    Health(HealthReport<N>),
2789    /// A tracked account's storage root moved with no covering decoder — a
2790    /// coverage gap the per-block root gate detected (Phase-8 step 4).
2791    CoverageGap(CoverageGapReport<N>),
2792    /// Runtime or handler error.
2793    Error(ReactiveErrorReport<N>),
2794}
2795
2796/// Report emitted after an ordered source control is accepted.
2797#[derive(Clone, Debug, PartialEq, Eq)]
2798pub struct ChainControlReport {
2799    /// Control in its original delivery order.
2800    pub control: ChainControl,
2801}
2802
2803/// Input acceptance report.
2804#[derive(Clone, Debug)]
2805pub struct InputReport<N: Network = Ethereum> {
2806    /// Input reference.
2807    pub input_ref: InputRef,
2808    /// Input context.
2809    pub context: ReactiveContext,
2810    /// Provider session that originated the input, when known.
2811    pub provider: Option<ProviderRef>,
2812    /// Network marker.
2813    pub _network: PhantomData<N>,
2814}
2815
2816/// Decoding report.
2817#[derive(Clone, Debug)]
2818pub struct DecodedReport<N: Network = Ethereum> {
2819    /// Input reference.
2820    pub input_ref: InputRef,
2821    /// Handler ids that matched the input.
2822    pub handler_ids: Vec<HandlerId>,
2823    /// Network marker.
2824    pub _network: PhantomData<N>,
2825}
2826
2827/// Applied state report.
2828#[derive(Clone, Debug)]
2829pub struct AppliedReport<N: Network = Ethereum> {
2830    /// Input reference.
2831    pub input_ref: InputRef,
2832    /// Handler that produced the applied effects.
2833    pub handler_id: HandlerId,
2834    /// State effect quality.
2835    pub quality: StateEffectQuality,
2836    /// Labels emitted by the handler.
2837    pub tags: Vec<ReportTag>,
2838    /// Merged state diff from applied updates and invalidations.
2839    pub diff: StateDiff,
2840    /// State updates applied through the cache.
2841    pub state_updates: Vec<StateUpdate>,
2842    /// Invalidation requests lowered to purge updates.
2843    pub invalidations: Vec<InvalidationRequest>,
2844    /// Resync requests surfaced for a scheduler.
2845    pub resyncs: Vec<ResyncRequest>,
2846    /// Speculative requests surfaced for downstream users.
2847    pub speculative: Vec<SpeculativeRequest>,
2848    /// Hook signals emitted by the handler.
2849    pub hook_signals: Vec<HookSignal>,
2850    /// Network marker.
2851    pub _network: PhantomData<N>,
2852}
2853
2854/// Report of the storage resync requests executed during an ingest cycle: the
2855/// requests considered, the authoritative updates built from successful fetches
2856/// (and their applied diff), and any targets that could not be resynced.
2857#[derive(Clone, Debug, Default, PartialEq, Eq)]
2858pub struct ResyncReport {
2859    /// Requests considered by the resync execution pass.
2860    pub requested: Vec<ResyncRequest>,
2861    /// Authoritative state updates built from successful resync fetches.
2862    pub state_updates: Vec<StateUpdate>,
2863    /// Diff returned by applying [`state_updates`](Self::state_updates).
2864    pub diff: StateDiff,
2865    /// Targets that could not be resynced.
2866    pub failed: Vec<ResyncFailure>,
2867}
2868
2869/// One resync target that could not be fetched or applied.
2870#[derive(Clone, Debug, PartialEq, Eq)]
2871pub struct ResyncFailure {
2872    /// Request that produced the failed target.
2873    pub request_id: ResyncId,
2874    /// Block selection used for the failed target.
2875    pub block: ResyncBlock,
2876    /// Target that could not be resynced.
2877    pub target: ResyncTarget,
2878    /// Stable failure classification for retry policy and metrics.
2879    pub kind: ResyncFailureKind,
2880    /// Human-readable failure reason.
2881    pub message: String,
2882}
2883
2884/// Stable classification for a failed resync target.
2885#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2886#[non_exhaustive]
2887pub enum ResyncFailureKind {
2888    /// A storage target could not be fetched because no storage batch fetcher is configured.
2889    MissingStorageFetcher,
2890    /// The storage batch fetcher returned an error for the requested slot.
2891    StorageFetchFailed,
2892    /// The storage batch fetcher did not return a result for the requested slot.
2893    StorageFetchOmitted,
2894    /// An account target could not be fetched because no account proof fetcher is configured.
2895    MissingAccountFetcher,
2896    /// The account proof fetcher returned an error for the requested address.
2897    AccountFetchFailed,
2898    /// The account proof fetcher did not return a result for the requested address.
2899    AccountFetchOmitted,
2900}
2901
2902/// Block processing report.
2903#[derive(Clone, Debug)]
2904pub struct BlockReport<N: Network = Ethereum> {
2905    /// Block reference, when known.
2906    pub block: Option<BlockRef>,
2907    /// Input references committed for the block.
2908    pub inputs: Vec<InputRef>,
2909    /// Network marker.
2910    pub _network: PhantomData<N>,
2911}
2912
2913/// Report of a detected reorg and the recovery it performed: the dropped
2914/// block(s) and inputs, the exact rollback updates applied for reversible dropped
2915/// effects, the conservative purge updates for irreversible ones, the canceled
2916/// hash-pinned resyncs, and why recovery ran.
2917///
2918/// Recovery only covers blocks still resident in the journal. If a reorg runs
2919/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
2920/// appear here and their effects are neither rolled back nor purged (the runtime
2921/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
2922/// backstop for that span. Checkpointed engine ingestion rejects explicit,
2923/// implicit-parent, and removed-log recovery outside the retained journal
2924/// instead of producing and durably acknowledging a partial report.
2925/// Non-checkpointed ingestion still emits this report when no journal entry was
2926/// recoverable; in that case `dropped` identifies the signal/head when known,
2927/// while `dropped_blocks` and rollback effects are empty.
2928#[derive(Clone, Debug)]
2929pub struct ReorgReport<N: Network = Ethereum> {
2930    /// First dropped block, when known.
2931    pub dropped: Option<BlockRef>,
2932    /// Blocks dropped from the journal, in ascending journal order.
2933    pub dropped_blocks: Vec<BlockRef>,
2934    /// Input references that belonged to dropped blocks.
2935    pub dropped_inputs: Vec<InputRef>,
2936    /// Exact rollback updates applied for reversible dropped effects.
2937    pub rollback_updates: Vec<StateUpdate>,
2938    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
2939    pub rollback_diff: StateDiff,
2940    /// Conservative purge updates applied for irreversible dropped effects.
2941    pub purge_updates: Vec<StateUpdate>,
2942    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
2943    pub purge_diff: StateDiff,
2944    /// Hash-pinned pending resync requests canceled because their block was dropped.
2945    pub canceled_resyncs: Vec<ResyncRequest>,
2946    /// Reorg trigger.
2947    pub reason: ReorgReason,
2948    /// Network marker.
2949    pub _network: PhantomData<N>,
2950}
2951
2952/// Reason reorg recovery ran.
2953#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2954pub enum ReorgReason {
2955    /// A provider emitted an Alloy removed log.
2956    RemovedLog,
2957    /// The input context explicitly marked an input as reorged.
2958    ReorgedInput,
2959    /// A canonical block did not connect to the journaled head.
2960    ParentMismatch,
2961    /// A subscriber delivered an explicit canonical branch transition.
2962    Explicit,
2963}
2964
2965/// Report of a forward gap in the canonical block sequence: an arriving block
2966/// whose number is more than one past the last-seen head, so the blocks in
2967/// between were never observed (for example during a subscription disconnect).
2968///
2969/// The arriving block is still accepted and applied — the chain extends — so this
2970/// report only makes the skipped span observable; it does not drop the block. The
2971/// span `from..=to` is inclusive of both endpoints.
2972#[derive(Clone, Debug)]
2973pub struct MissedRangeReport<N: Network = Ethereum> {
2974    /// First skipped block (`last-seen block number + 1`).
2975    pub from: u64,
2976    /// Last skipped block (`arriving block number - 1`).
2977    pub to: u64,
2978    /// The arriving block's number.
2979    pub block: u64,
2980    /// Network marker.
2981    pub _network: PhantomData<N>,
2982}
2983
2984/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
2985/// caused it and delivered to hooks through the normal dispatch path.
2986#[derive(Clone, Debug)]
2987pub struct HealthReport<N: Network = Ethereum> {
2988    /// Health state before the transition.
2989    pub from: CacheHealth,
2990    /// Health state after the transition.
2991    pub to: CacheHealth,
2992    /// Block number associated with the transition, when known.
2993    pub block: Option<u64>,
2994    /// Network marker.
2995    pub _network: PhantomData<N>,
2996}
2997
2998/// Report that a tracked account's storage root moved on a canonical block that
2999/// no decoder covered — a coverage gap surfaced by the per-block root gate
3000/// (Phase-8 step 4).
3001///
3002/// An account's `storageHash` is a collision-resistant commitment over all of its
3003/// storage, so a moved root proves *something* under the account changed. When
3004/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
3005/// batch's touched-address set does not include it, the change arrived through a
3006/// path no decoder observed. The runtime emits this report (delivered through the
3007/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
3008/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
3009/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
3010#[derive(Clone, Debug)]
3011pub struct CoverageGapReport<N: Network = Ethereum> {
3012    /// The tracked account whose root moved with no covering decoder.
3013    pub address: Address,
3014    /// The canonical block number at which the gap was observed.
3015    pub block: u64,
3016    /// Network marker.
3017    pub _network: PhantomData<N>,
3018}
3019
3020/// Report of a non-fatal error surfaced during an ingest cycle, with the
3021/// associated input (when known) and a human-readable message.
3022#[derive(Clone, Debug)]
3023pub struct ReactiveErrorReport<N: Network = Ethereum> {
3024    /// Input associated with the error, when known.
3025    pub input_ref: Option<InputRef>,
3026    /// Error message.
3027    pub message: String,
3028    /// Network marker.
3029    pub _network: PhantomData<N>,
3030}
3031
3032/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
3033/// [`ReactiveRuntime::ingest_batch_with_resync`].
3034#[derive(Clone, Debug)]
3035pub struct ReactiveBatchReport<N: Network = Ethereum> {
3036    /// Applied reports in commit order.
3037    pub applied: Vec<AppliedReport<N>>,
3038    /// Resync requests surfaced during the batch.
3039    pub resyncs: Vec<ResyncRequest>,
3040    /// Speculative requests surfaced during the batch.
3041    pub speculative: Vec<SpeculativeRequest>,
3042    /// Hook reports dispatched after mutation phases.
3043    pub reports: Vec<Arc<ReactiveReport<N>>>,
3044}
3045
3046impl<N: Network> Default for ReactiveBatchReport<N> {
3047    fn default() -> Self {
3048        Self {
3049            applied: Vec::new(),
3050            resyncs: Vec::new(),
3051            speculative: Vec::new(),
3052            reports: Vec::new(),
3053        }
3054    }
3055}
3056
3057/// Error returned by a handler.
3058#[derive(Clone, Debug, PartialEq, Eq)]
3059pub struct HandlerError {
3060    message: String,
3061}
3062
3063impl HandlerError {
3064    /// Create a handler error from a message.
3065    pub fn new(message: impl Into<String>) -> Self {
3066        Self {
3067            message: message.into(),
3068        }
3069    }
3070}
3071
3072impl fmt::Display for HandlerError {
3073    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3074        self.message.fmt(f)
3075    }
3076}
3077
3078impl std::error::Error for HandlerError {}
3079
3080impl From<String> for HandlerError {
3081    fn from(message: String) -> Self {
3082        Self::new(message)
3083    }
3084}
3085
3086impl From<&str> for HandlerError {
3087    fn from(message: &str) -> Self {
3088        Self::new(message)
3089    }
3090}
3091
3092/// Runtime error.
3093#[derive(Debug, thiserror::Error)]
3094#[non_exhaustive]
3095pub enum ReactiveError {
3096    /// Handler returned an error.
3097    #[error("handler `{handler_id}` failed: {source}")]
3098    HandlerFailed {
3099        /// Handler id.
3100        handler_id: HandlerId,
3101        /// Handler error.
3102        source: HandlerError,
3103    },
3104    /// Multiple handlers emitted incompatible absolute writes for one input.
3105    #[error(
3106        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
3107    )]
3108    ConflictingEffects {
3109        /// Input reference.
3110        input_ref: Box<InputRef>,
3111        /// Conflicting target.
3112        target: Box<EffectTarget>,
3113        /// First handler id.
3114        first: HandlerId,
3115        /// Second handler id.
3116        second: HandlerId,
3117    },
3118    /// Pending inputs attempted to mutate canonical cache state.
3119    #[error(
3120        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
3121    )]
3122    InvalidPendingEffect {
3123        /// Input reference.
3124        input_ref: Box<InputRef>,
3125        /// Handler id.
3126        handler_id: HandlerId,
3127        /// Effect kind.
3128        effect_kind: &'static str,
3129    },
3130    /// A subscriber supplied payload metadata that is incomplete or
3131    /// contradicts the accompanying context.
3132    #[error("invalid reactive input record: {message}")]
3133    InvalidInputRecord {
3134        /// Human-readable invariant violation.
3135        message: String,
3136    },
3137    /// A source delivered a contradictory chain-lifecycle transition.
3138    #[error("invalid chain control: {message}")]
3139    InvalidChainControl {
3140        /// Human-readable invariant violation.
3141        message: String,
3142    },
3143    /// Owner-scoped catch-up would mutate a historical block for which the
3144    /// runtime has no rollback journal entry.
3145    #[error(
3146        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3147    )]
3148    OwnerCatchupOutsideJournal {
3149        /// Catch-up block number.
3150        number: u64,
3151        /// Catch-up block hash.
3152        hash: B256,
3153    },
3154    /// Registration error.
3155    #[error(transparent)]
3156    Register(#[from] RegisterError),
3157}
3158
3159/// Handler registration error.
3160#[derive(Debug, thiserror::Error)]
3161#[non_exhaustive]
3162pub enum RegisterError {
3163    /// Duplicate handler id.
3164    #[error("handler id `{0}` is already registered")]
3165    DuplicateHandler(HandlerId),
3166}
3167
3168/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
3169/// runtime and subscriber sides.
3170#[derive(Debug, thiserror::Error)]
3171#[non_exhaustive]
3172pub enum ReactiveEngineRegisterError {
3173    /// Runtime registry rejected the handler.
3174    #[error(transparent)]
3175    Register(#[from] RegisterError),
3176    /// Subscriber rejected the handler's interests.
3177    #[error(transparent)]
3178    Subscriber(#[from] SubscriberError),
3179    /// Owner-only history was not constrained to one hash-certified block that
3180    /// remains in the runtime rollback journal.
3181    #[error(
3182        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
3183    )]
3184    BackfillOutsideJournal {
3185        /// First requested block.
3186        start_block: u64,
3187        /// Inclusive requested upper bound, if bounded.
3188        end_block: Option<u64>,
3189        /// Hash-certified anchor supplied by the caller, if any.
3190        retained_anchor: Option<BlockRef>,
3191    },
3192}
3193
3194/// Error adopting an RPC snapshot as a runtime's canonical continuity
3195/// baseline.
3196#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
3197#[non_exhaustive]
3198pub enum ReactiveBaselineError {
3199    /// Runtime or engine delivery state already contains lifecycle work.
3200    #[error("cannot adopt a canonical baseline after reactive processing has started")]
3201    ActiveRuntime,
3202    /// An exact repeat is allowed, but the requested baseline conflicts with
3203    /// the previously adopted block.
3204    #[error(
3205        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
3206    )]
3207    ConflictingBaseline {
3208        /// Existing baseline number.
3209        existing_number: u64,
3210        /// Existing baseline hash.
3211        existing_hash: B256,
3212        /// Requested baseline number.
3213        requested_number: u64,
3214        /// Requested baseline hash.
3215        requested_hash: B256,
3216    },
3217    /// Typed baseline and cache identify different chains.
3218    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
3219    CacheChainMismatch {
3220        /// Chain declared by the baseline.
3221        baseline_chain_id: u64,
3222        /// Chain configured on the cache.
3223        cache_chain_id: u64,
3224    },
3225    /// The cache is not hash-pinned to the exact adopted canonical block.
3226    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
3227    CacheBlockMismatch {
3228        /// Expected baseline number.
3229        number: u64,
3230        /// Expected baseline hash.
3231        hash: B256,
3232    },
3233}
3234
3235/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
3236/// and runtime ingestion.
3237#[derive(Debug, thiserror::Error)]
3238#[non_exhaustive]
3239pub enum ReactiveEngineError {
3240    /// Subscriber polling failed.
3241    #[error(transparent)]
3242    Subscriber(#[from] SubscriberError),
3243    /// Runtime ingestion failed.
3244    #[error(transparent)]
3245    Runtime(ReactiveError),
3246    /// Canonical cold-start baseline adoption failed.
3247    #[error(transparent)]
3248    Baseline(#[from] ReactiveBaselineError),
3249    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
3250    /// did not commit. The subscriber may replay the batch.
3251    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3252    Acknowledgement(#[source] SubscriberError),
3253    /// Runtime ingestion succeeded, but the resulting cache state could not be
3254    /// durably checkpointed. The engine retains the commit in memory and must
3255    /// retry it before polling another batch.
3256    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3257    Checkpoint(#[source] DurableCheckpointError),
3258    /// A checkpointed ingest had no canonical block to bind the state to.
3259    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3260    MissingCheckpointBlock,
3261    /// Speculative pre-confirmation state is intentionally excluded from
3262    /// canonical durable checkpoints.
3263    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3264    PreconfirmationNotCheckpointable,
3265    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3266    #[error("failed to encode durable reactive runtime state: {0}")]
3267    RuntimeCheckpoint(String),
3268    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3269    /// to ordinary acknowledgement ordering without first completing it.
3270    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3271    PendingCheckpointCommit,
3272    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3273    /// switch to checkpointed ingestion and retroactively make it durable.
3274    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3275    PendingAcknowledgementCommit,
3276    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3277    /// commit metadata. Only the combined polling helpers can preserve the
3278    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3279    #[error(
3280        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3281    )]
3282    UncommittedDeliveryMetadata,
3283    /// Subscriber and cache are bound to different chains.
3284    #[error(
3285        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3286    )]
3287    SubscriberChainMismatch {
3288        /// Chain reported by the subscriber.
3289        subscriber_chain_id: u64,
3290        /// Chain configured on the cache.
3291        cache_chain_id: u64,
3292    },
3293    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3294    #[error("subscriber does not advertise durable replay support")]
3295    SubscriberNotDurable,
3296    /// A restored delivery token predates or otherwise lacks the core witness
3297    /// needed to prove that a replay carries the same delivery.
3298    #[error(
3299        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3300    )]
3301    MissingReplayWitness,
3302    /// A source reused a committed token for different records, routing,
3303    /// controls, chain identity, or provider resume state.
3304    #[error("replayed delivery token does not match its committed delivery witness")]
3305    ReplayDeliveryMismatch,
3306    /// The stable delivery witness could not be encoded.
3307    #[error("failed to encode durable delivery witness: {0}")]
3308    DeliveryWitness(String),
3309    /// A tokened network-generic header/body cannot be witnessed completely
3310    /// without a source-supplied canonical wire commitment.
3311    #[error(
3312        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3313    )]
3314    MissingPayloadCommitment,
3315    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3316    /// would bind those unrelated mutations to the older delivery metadata.
3317    #[error(
3318        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3319    )]
3320    PendingCheckpointCacheChanged {
3321        /// Generation immediately after the staged batch was ingested.
3322        staged_generation: u64,
3323        /// Generation observed when checkpoint commit was retried.
3324        current_generation: u64,
3325    },
3326    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3327    /// runtime no longer retains every potentially affected journal entry.
3328    #[error(
3329        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3330    )]
3331    CheckpointReorgOutsideJournal {
3332        /// Last block shared by the old and replacement branches.
3333        common_ancestor: u64,
3334        /// Oldest retained effect-bearing journal block, if any.
3335        oldest_journaled: Option<u64>,
3336        /// Configured maximum journal entries.
3337        journal_depth: usize,
3338    },
3339    /// Owner-scoped catch-up would mutate a historical block for which the
3340    /// runtime has no rollback journal entry.
3341    #[error(
3342        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3343    )]
3344    OwnerCatchupOutsideJournal {
3345        /// Catch-up block number.
3346        number: u64,
3347        /// Catch-up block hash.
3348        hash: B256,
3349    },
3350}
3351
3352impl From<ReactiveError> for ReactiveEngineError {
3353    fn from(error: ReactiveError) -> Self {
3354        match error {
3355            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3356                Self::OwnerCatchupOutsideJournal { number, hash }
3357            }
3358            error => Self::Runtime(error),
3359        }
3360    }
3361}
3362
3363/// Error restoring a durable checkpoint anchor into an active runtime.
3364#[derive(Debug, thiserror::Error)]
3365#[non_exhaustive]
3366pub enum ReactiveCheckpointRestoreError {
3367    /// A runtime with canonical journal state cannot be silently rewound.
3368    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3369    ActiveRuntime,
3370    /// Stored runtime recovery bytes were malformed or unsupported.
3371    #[error("invalid durable reactive runtime state: {0}")]
3372    InvalidRuntimeCheckpoint(String),
3373    /// Checkpoint identity or cache restoration failed before activation.
3374    #[error(transparent)]
3375    Checkpoint(#[from] DurableCheckpointError),
3376    /// Subscriber rejected the restored durable cursor or canonical position.
3377    #[error("subscriber rejected durable resume position: {0}")]
3378    Subscriber(#[source] SubscriberError),
3379    /// Subscriber and checkpoint identities name different chains.
3380    #[error(
3381        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3382    )]
3383    SubscriberChainMismatch {
3384        /// Chain reported by the subscriber.
3385        subscriber_chain_id: u64,
3386        /// Chain committed by the checkpoint identity.
3387        checkpoint_chain_id: u64,
3388    },
3389    /// Restoring event continuity requires a durable replay-capable subscriber.
3390    #[error("subscriber does not advertise durable replay support")]
3391    SubscriberNotDurable,
3392}
3393
3394/// Result of one crash-safe subscriber ingest cycle.
3395#[derive(Clone, Debug)]
3396#[non_exhaustive]
3397pub enum CheckpointedIngest<N: Network = Ethereum> {
3398    /// A new batch was ingested, durably checkpointed, and acknowledged.
3399    Applied(ReactiveBatchReport<N>),
3400    /// The checkpoint already contained this replayed delivery token, so the
3401    /// batch was acknowledged without applying its effects twice.
3402    ReplayAcknowledged,
3403}
3404
3405/// Absolute write target used for conflict reports.
3406#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3407pub enum EffectTarget {
3408    /// Storage slot target.
3409    StorageSlot {
3410        /// Contract address.
3411        address: Address,
3412        /// Storage slot.
3413        slot: U256,
3414    },
3415    /// Account balance target.
3416    AccountBalance {
3417        /// Account address.
3418        address: Address,
3419    },
3420    /// Account nonce target.
3421    AccountNonce {
3422        /// Account address.
3423        address: Address,
3424    },
3425    /// Account code target.
3426    AccountCode {
3427        /// Account address.
3428        address: Address,
3429    },
3430    /// Masked storage slot target.
3431    MaskedStorageSlot {
3432        /// Contract address.
3433        address: Address,
3434        /// Storage slot.
3435        slot: U256,
3436        /// Bit mask.
3437        mask: U256,
3438    },
3439}
3440
3441#[derive(Clone, Debug, PartialEq, Eq)]
3442enum AbsoluteValue {
3443    U256(U256),
3444    U64(u64),
3445    Bytes(Bytes),
3446}
3447
3448/// Reactive runtime.
3449pub struct ReactiveRuntime<N: Network = Ethereum> {
3450    registry: ReactiveRegistry<N>,
3451    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3452    config: ReactiveConfig,
3453    journal: VecDeque<BlockJournal<N>>,
3454    coverage_head: Option<BlockRef>,
3455    pending_resyncs: Vec<ResyncRequest>,
3456    health: CacheHealth,
3457    safe_head: Option<BlockRef>,
3458    finalized_head: Option<BlockRef>,
3459    metrics: CacheMetrics,
3460    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3461    ///
3462    /// `None` by default (behavior unchanged); populated by
3463    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3464    /// present, applying a canonical handler storage-slot effect stamps the
3465    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3466    /// so event-maintained slots stop being needlessly re-verified while aging to
3467    /// volatile once the clock passes `N`.
3468    freshness: Option<FreshnessRegistry>,
3469    /// Per-account tracking registry consulted by the per-block root gate
3470    /// (Phase-8 step 4). Empty by default; populated by
3471    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3472    tracking: HashMap<Address, TrackingPolicy>,
3473    /// Per-account root/field baselines the gate diffs against across blocks.
3474    /// Adopted on first probe and re-adopted on every observed move.
3475    tracked_roots: HashMap<Address, TrackedRoot>,
3476    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3477    root_gate_cadence: RootGateCadence,
3478    /// Canonical block of the last root-gate firing. `None` until the first
3479    /// firing (which happens at the first canonical block ever seen, so
3480    /// baseline adoption never waits a full cadence window).
3481    last_gate_block: Option<u64>,
3482    /// Union of decoder-touched addresses since the last root-gate firing,
3483    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3484    /// touched" must judge against every covered write in the window, or a
3485    /// decoder-covered write in a skipped block would false-positive as a
3486    /// [`ReactiveReport::CoverageGap`].
3487    touched_since_gate: HashSet<Address>,
3488    /// Disposable pre-confirmation branch layered over the canonical cache.
3489    /// This is deliberately omitted from durable runtime checkpoints.
3490    preconfirmed_branch: Option<PreconfirmedBranch>,
3491}
3492
3493#[derive(Clone)]
3494struct PreconfirmedBranch {
3495    flashblock: FlashblockRef,
3496    canonical_cache: EvmCacheStateSnapshot,
3497}
3498
3499#[derive(Clone, Debug)]
3500struct BlockJournal<N: Network = Ethereum> {
3501    block: BlockRef,
3502    inputs: Vec<InputRef>,
3503    applied: Vec<AppliedReport<N>>,
3504    handler_ids: Vec<HandlerId>,
3505    resynced: Vec<ResyncReport>,
3506    rollback_diffs: Vec<StateDiff>,
3507}
3508
3509const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3;
3510
3511#[derive(serde::Serialize, serde::Deserialize)]
3512struct DurableRuntimeCheckpoint {
3513    version: u32,
3514    safe_head: Option<BlockRef>,
3515    finalized_head: Option<BlockRef>,
3516    health: CacheHealth,
3517    pending_resyncs: Vec<ResyncRequest>,
3518    coverage_head: Option<BlockRef>,
3519    journal: Vec<DurableBlockJournal>,
3520    freshness: Option<FreshnessRegistry>,
3521    tracking: HashMap<Address, TrackingPolicy>,
3522    tracked_roots: HashMap<Address, TrackedRoot>,
3523    root_gate_cadence: RootGateCadence,
3524    last_gate_block: Option<u64>,
3525    touched_since_gate: HashSet<Address>,
3526    metrics: CacheMetricsSnapshot,
3527}
3528
3529#[derive(serde::Serialize, serde::Deserialize)]
3530struct DurableBlockJournal {
3531    block: BlockRef,
3532    handler_ids: Vec<HandlerId>,
3533    rollback_diffs: Vec<StateDiff>,
3534}
3535
3536struct DurableRuntimeRestorePlan {
3537    checkpoint: Option<DurableRuntimeCheckpoint>,
3538    fallback_history: Vec<BlockRef>,
3539}
3540
3541impl DurableRuntimeRestorePlan {
3542    fn canonical_history(&self) -> Vec<BlockRef> {
3543        self.checkpoint.as_ref().map_or_else(
3544            || self.fallback_history.clone(),
3545            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3546        )
3547    }
3548}
3549
3550#[derive(Clone)]
3551struct ReactiveRuntimeState<N: Network> {
3552    journal: VecDeque<BlockJournal<N>>,
3553    coverage_head: Option<BlockRef>,
3554    pending_resyncs: Vec<ResyncRequest>,
3555    health: CacheHealth,
3556    safe_head: Option<BlockRef>,
3557    finalized_head: Option<BlockRef>,
3558    freshness: Option<FreshnessRegistry>,
3559    tracking: HashMap<Address, TrackingPolicy>,
3560    tracked_roots: HashMap<Address, TrackedRoot>,
3561    root_gate_cadence: RootGateCadence,
3562    last_gate_block: Option<u64>,
3563    touched_since_gate: HashSet<Address>,
3564    metrics: CacheMetricsSnapshot,
3565}
3566
3567#[derive(Clone)]
3568struct ChainControlState {
3569    journal_invalidated_from: Option<u64>,
3570    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3571}
3572
3573/// Canonical branch fragments already rolled back by the current atomic batch.
3574///
3575/// Providers commonly emit one removed notification per log after one signal
3576/// has already drained the complete dropped block (and every retained
3577/// descendant). Explicit reorg controls can be followed by the same redundant
3578/// lifecycle records. Exact identities decide whether removal recovery is
3579/// redundant; numeric spans are retained only as same-batch proof for a
3580/// parentless replacement after those exact journal entries were drained.
3581#[derive(Default)]
3582struct BatchDroppedCanonical {
3583    identities: HashSet<(u64, B256)>,
3584    implicit_spans: Vec<(u64, u64)>,
3585}
3586
3587impl BatchDroppedCanonical {
3588    fn covers_implicit_number(&self, number: u64) -> bool {
3589        self.implicit_spans
3590            .iter()
3591            .any(|(from, through)| number >= *from && number <= *through)
3592    }
3593
3594    fn contains(&self, block: &BlockRef) -> bool {
3595        self.identities.contains(&(block.number, block.hash))
3596    }
3597
3598    fn record_identity(&mut self, block: &BlockRef) {
3599        self.identities.insert((block.number, block.hash));
3600    }
3601
3602    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3603        self.identities.insert((old_tip.number, old_tip.hash));
3604    }
3605
3606    fn record_drained(&mut self, blocks: &[BlockRef]) {
3607        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3608            return;
3609        };
3610        let through = blocks
3611            .iter()
3612            .map(|block| block.number)
3613            .max()
3614            .expect("a non-empty drained set has a maximum");
3615        self.implicit_spans.push((from, through));
3616        self.identities
3617            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3618    }
3619}
3620
3621/// Registry and router for provider-neutral reactive handlers.
3622///
3623/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3624/// consolidated provider-side log filters for subscription setup, and routes
3625/// provider logs back to the exact matching log interests. Consolidated filters
3626/// may be safe supersets; [`Self::route_log`] always re-checks the original
3627/// [`LogInterest`] and its local matcher before returning a route.
3628pub struct ReactiveRegistry<N: Network = Ethereum> {
3629    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3630    handler_positions: HashMap<HandlerId, u128>,
3631    next_handler_position: u128,
3632    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3633    fallback_log_handlers: BTreeSet<u128>,
3634    data_slice_shapes: HashMap<(usize, usize), usize>,
3635}
3636
3637struct RegisteredHandler<N: Network = Ethereum> {
3638    id: HandlerId,
3639    handler: Arc<dyn ReactiveHandler<N>>,
3640    interests: Vec<ReactiveInterest<N>>,
3641    has_log_interests: bool,
3642    log_route_index: Option<LogRouteIndex>,
3643}
3644
3645impl<N: Network> Default for ReactiveRegistry<N> {
3646    fn default() -> Self {
3647        Self::new()
3648    }
3649}
3650
3651impl<N: Network> ReactiveRegistry<N> {
3652    /// Create an empty registry.
3653    pub fn new() -> Self {
3654        Self {
3655            handlers: BTreeMap::new(),
3656            handler_positions: HashMap::new(),
3657            next_handler_position: 0,
3658            indexed_log_handlers: HashMap::new(),
3659            fallback_log_handlers: BTreeSet::new(),
3660            data_slice_shapes: HashMap::new(),
3661        }
3662    }
3663
3664    /// Register a handler, preserving registration order.
3665    ///
3666    /// Duplicate handler ids are rejected with
3667    /// [`RegisterError::DuplicateHandler`].
3668    ///
3669    /// # Errors
3670    ///
3671    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3672    /// registered.
3673    pub fn register_handler(
3674        &mut self,
3675        handler: Arc<dyn ReactiveHandler<N>>,
3676    ) -> Result<(), RegisterError> {
3677        let id = handler.id();
3678        if self.handler_positions.contains_key(&id) {
3679            return Err(RegisterError::DuplicateHandler(id));
3680        }
3681        let interests = handler.interests();
3682        self.insert_handler_prepared(id, handler, interests);
3683        Ok(())
3684    }
3685
3686    fn insert_handler_prepared(
3687        &mut self,
3688        id: HandlerId,
3689        handler: Arc<dyn ReactiveHandler<N>>,
3690        interests: Vec<ReactiveInterest<N>>,
3691    ) {
3692        debug_assert!(!self.handler_positions.contains_key(&id));
3693        let has_log_interests = interests
3694            .iter()
3695            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3696        let log_route_index = handler.log_route_index();
3697        if self.next_handler_position == u128::MAX {
3698            self.compact_handler_positions();
3699        }
3700        let position = self.next_handler_position;
3701        self.next_handler_position += 1;
3702        self.handler_positions.insert(id.clone(), position);
3703        if let Some(index) = &log_route_index {
3704            for key in index.keys() {
3705                if let LogRouteKey::DataSlice { offset, value } = key {
3706                    *self
3707                        .data_slice_shapes
3708                        .entry((*offset, value.len()))
3709                        .or_default() += 1;
3710                }
3711                self.indexed_log_handlers
3712                    .entry(key.clone())
3713                    .or_default()
3714                    .insert(position);
3715            }
3716        } else if has_log_interests {
3717            self.fallback_log_handlers.insert(position);
3718        }
3719        self.handlers.insert(
3720            position,
3721            RegisteredHandler {
3722                id,
3723                handler,
3724                interests,
3725                has_log_interests,
3726                log_route_index,
3727            },
3728        );
3729    }
3730
3731    /// Remove one handler by id, leaving all other handlers and interests intact.
3732    ///
3733    /// Returns the removed handler when the id was registered. Cache eviction is
3734    /// intentionally outside this API: unregistering stops future routing and
3735    /// decode for the handler only.
3736    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3737        let position = self.handler_positions.remove(id)?;
3738        let registered = self.handlers.remove(&position)?;
3739        if let Some(index) = &registered.log_route_index {
3740            for key in index.keys() {
3741                let remove_bucket = self
3742                    .indexed_log_handlers
3743                    .get_mut(key)
3744                    .is_some_and(|owners| {
3745                        owners.remove(&position);
3746                        owners.is_empty()
3747                    });
3748                if remove_bucket {
3749                    self.indexed_log_handlers.remove(key);
3750                }
3751                if let LogRouteKey::DataSlice { offset, value } = key {
3752                    let shape = (*offset, value.len());
3753                    let remove_shape =
3754                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3755                            *count -= 1;
3756                            *count == 0
3757                        });
3758                    if remove_shape {
3759                        self.data_slice_shapes.remove(&shape);
3760                    }
3761                }
3762            }
3763        } else {
3764            self.fallback_log_handlers.remove(&position);
3765        }
3766        Some(registered.handler)
3767    }
3768
3769    /// Return true when `id` is currently registered.
3770    pub fn contains_handler(&self, id: &HandlerId) -> bool {
3771        self.handler_positions.contains_key(id)
3772    }
3773
3774    /// Ids of all registered handlers, in registration (= routing) order.
3775    pub fn handler_ids(&self) -> Vec<HandlerId> {
3776        self.handlers
3777            .values()
3778            .map(|handler| handler.id.clone())
3779            .collect()
3780    }
3781
3782    /// Borrow the interests owned by one handler.
3783    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
3784        self.handler_positions
3785            .get(id)
3786            .and_then(|position| self.handlers.get(position))
3787            .map(|registered| registered.interests.as_slice())
3788    }
3789
3790    /// Return all registered interests in handler registration order.
3791    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
3792        self.handlers
3793            .values()
3794            .flat_map(|handler| handler.interests.clone())
3795            .collect()
3796    }
3797
3798    /// Return consolidated provider-side log filters.
3799    ///
3800    /// Filters are emitted in deterministic first-registration order by
3801    /// compatible block option. Within each returned filter, address and topic
3802    /// sets are unioned independently, which can intentionally overfetch. Use
3803    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
3804    pub fn log_subscription_filters(&self) -> Vec<Filter> {
3805        let mut filters = Vec::new();
3806        for interest in self.log_interests() {
3807            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
3808        }
3809        filters
3810    }
3811
3812    /// Route a log to exact matching handler interests.
3813    ///
3814    /// Routes are returned in handler registration order. Each handler appears
3815    /// at most once for a log, using the first matching log interest declared by
3816    /// that handler.
3817    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
3818        self.log_handler_candidates(log)
3819            .into_iter()
3820            .filter_map(|handler| handler.route_log(log))
3821            .collect()
3822    }
3823
3824    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
3825        let mut indexed_positions = Vec::new();
3826        if let Some(indexed) = self
3827            .indexed_log_handlers
3828            .get(&LogRouteKey::Emitter(log.address()))
3829        {
3830            indexed_positions.extend(indexed.iter().copied());
3831        }
3832        for (index, value) in log.topics().iter().copied().enumerate() {
3833            if let Some(indexed) = self
3834                .indexed_log_handlers
3835                .get(&LogRouteKey::Topic { index, value })
3836            {
3837                indexed_positions.extend(indexed.iter().copied());
3838            }
3839        }
3840        let data = log.inner.data.data.as_ref();
3841        for &(offset, len) in self.data_slice_shapes.keys() {
3842            let Some(end) = offset.checked_add(len) else {
3843                continue;
3844            };
3845            let Some(value) = data.get(offset..end) else {
3846                continue;
3847            };
3848            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
3849                offset,
3850                value: value.to_vec(),
3851            }) {
3852                indexed_positions.extend(indexed.iter().copied());
3853            }
3854        }
3855        if indexed_positions.is_empty() {
3856            if self.fallback_log_handlers.is_empty() {
3857                return Vec::new();
3858            }
3859            if !self.indexed_log_handlers.is_empty() {
3860                return self
3861                    .fallback_log_handlers
3862                    .iter()
3863                    .filter_map(|position| self.handlers.get(position))
3864                    .collect();
3865            }
3866            return self
3867                .handlers
3868                .values()
3869                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
3870                .collect();
3871        }
3872
3873        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
3874        indexed_positions.sort_unstable();
3875        indexed_positions.dedup();
3876        indexed_positions
3877            .into_iter()
3878            .filter_map(|position| self.handlers.get(&position))
3879            .collect()
3880    }
3881
3882    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
3883        self.handlers.values()
3884    }
3885
3886    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
3887        self.handlers.values().flat_map(|handler| {
3888            handler
3889                .interests
3890                .iter()
3891                .filter_map(|interest| match interest {
3892                    ReactiveInterest::Logs(interest) => Some(interest),
3893                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
3894                })
3895        })
3896    }
3897
3898    fn compact_handler_positions(&mut self) {
3899        let handlers = std::mem::take(&mut self.handlers);
3900        self.handler_positions.clear();
3901        self.indexed_log_handlers.clear();
3902        self.fallback_log_handlers.clear();
3903        self.data_slice_shapes.clear();
3904
3905        for (position, (_, handler)) in handlers.into_iter().enumerate() {
3906            let position = position as u128;
3907            self.handler_positions.insert(handler.id.clone(), position);
3908            if let Some(index) = &handler.log_route_index {
3909                for key in index.keys() {
3910                    if let LogRouteKey::DataSlice { offset, value } = key {
3911                        *self
3912                            .data_slice_shapes
3913                            .entry((*offset, value.len()))
3914                            .or_default() += 1;
3915                    }
3916                    self.indexed_log_handlers
3917                        .entry(key.clone())
3918                        .or_default()
3919                        .insert(position);
3920                }
3921            } else if handler.has_log_interests {
3922                self.fallback_log_handlers.insert(position);
3923            }
3924            self.handlers.insert(position, handler);
3925        }
3926        self.next_handler_position = self.handlers.len() as u128;
3927    }
3928}
3929
3930impl<N: Network> ReactiveRuntime<N> {
3931    /// Create an empty runtime.
3932    pub fn new(config: ReactiveConfig) -> Self {
3933        Self {
3934            registry: ReactiveRegistry::new(),
3935            hooks: Vec::new(),
3936            config,
3937            journal: VecDeque::new(),
3938            coverage_head: None,
3939            pending_resyncs: Vec::new(),
3940            health: CacheHealth::Healthy,
3941            safe_head: None,
3942            finalized_head: None,
3943            metrics: CacheMetrics::default(),
3944            freshness: None,
3945            tracking: HashMap::new(),
3946            tracked_roots: HashMap::new(),
3947            root_gate_cadence: RootGateCadence::default(),
3948            last_gate_block: None,
3949            touched_since_gate: HashSet::new(),
3950            preconfirmed_branch: None,
3951        }
3952    }
3953
3954    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
3955        ReactiveRuntimeState {
3956            journal: self.journal.clone(),
3957            coverage_head: self.coverage_head,
3958            pending_resyncs: self.pending_resyncs.clone(),
3959            health: self.health,
3960            safe_head: self.safe_head,
3961            finalized_head: self.finalized_head,
3962            freshness: self.freshness.clone(),
3963            tracking: self.tracking.clone(),
3964            tracked_roots: self.tracked_roots.clone(),
3965            root_gate_cadence: self.root_gate_cadence,
3966            last_gate_block: self.last_gate_block,
3967            touched_since_gate: self.touched_since_gate.clone(),
3968            metrics: self.metrics.snapshot(),
3969        }
3970    }
3971
3972    fn is_pristine_for_checkpoint_restore(&self) -> bool {
3973        self.preconfirmed_branch.is_none()
3974            && self.journal.is_empty()
3975            && self.coverage_head.is_none()
3976            && self.pending_resyncs.is_empty()
3977            && self.health == CacheHealth::Healthy
3978            && self.safe_head.is_none()
3979            && self.finalized_head.is_none()
3980            && self.tracked_roots.is_empty()
3981            && self.last_gate_block.is_none()
3982            && self.touched_since_gate.is_empty()
3983            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
3984    }
3985
3986    fn adopted_baseline_only(&self) -> Option<BlockRef> {
3987        let baseline = self.coverage_head?;
3988        let journal_is_baseline_only = if self.config.journal_depth == 0 {
3989            self.journal.is_empty()
3990        } else {
3991            self.journal.len() == 1
3992                && self.journal.front().is_some_and(|entry| {
3993                    entry.block == baseline
3994                        && entry.inputs.is_empty()
3995                        && entry.applied.is_empty()
3996                        && entry.handler_ids.is_empty()
3997                        && entry.resynced.is_empty()
3998                        && entry.rollback_diffs.is_empty()
3999                })
4000        };
4001        (self.preconfirmed_branch.is_none()
4002            && journal_is_baseline_only
4003            && self.pending_resyncs.is_empty()
4004            && self.health == CacheHealth::Healthy
4005            && self.safe_head.is_none()
4006            && self.finalized_head.is_none()
4007            && self.tracked_roots.is_empty()
4008            && self.last_gate_block.is_none()
4009            && self.touched_since_gate.is_empty()
4010            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
4011        .then_some(baseline)
4012    }
4013
4014    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
4015        self.journal = state.journal;
4016        self.coverage_head = state.coverage_head;
4017        self.pending_resyncs = state.pending_resyncs;
4018        self.health = state.health;
4019        self.safe_head = state.safe_head;
4020        self.finalized_head = state.finalized_head;
4021        self.freshness = state.freshness;
4022        self.tracking = state.tracking;
4023        self.tracked_roots = state.tracked_roots;
4024        self.root_gate_cadence = state.root_gate_cadence;
4025        self.last_gate_block = state.last_gate_block;
4026        self.touched_since_gate = state.touched_since_gate;
4027        self.metrics.restore(state.metrics);
4028    }
4029
4030    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
4031        // Metrics describe lifetime observations, including rejected attempts,
4032        // and are documented as monotonic. Roll back canonical/runtime state
4033        // without erasing the failure signal that caused the transaction to
4034        // abort.
4035        let metrics = self.metrics.snapshot();
4036        self.restore_state(state);
4037        self.metrics.restore(metrics);
4038    }
4039
4040    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
4041        let checkpoint = DurableRuntimeCheckpoint {
4042            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
4043            safe_head: self.safe_head,
4044            finalized_head: self.finalized_head,
4045            health: self.health,
4046            pending_resyncs: self.pending_resyncs.clone(),
4047            coverage_head: self.coverage_head,
4048            journal: self
4049                .journal
4050                .iter()
4051                .map(|entry| DurableBlockJournal {
4052                    block: entry.block,
4053                    handler_ids: entry.handler_ids.clone(),
4054                    rollback_diffs: entry.rollback_diffs.clone(),
4055                })
4056                .collect(),
4057            freshness: self.freshness.clone(),
4058            tracking: self.tracking.clone(),
4059            tracked_roots: self.tracked_roots.clone(),
4060            root_gate_cadence: self.root_gate_cadence,
4061            last_gate_block: self.last_gate_block,
4062            touched_since_gate: self.touched_since_gate.clone(),
4063            metrics: self.metrics.snapshot(),
4064        };
4065        bincode::serialize(&checkpoint)
4066            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
4067    }
4068
4069    fn plan_durable_checkpoint_restore(
4070        &self,
4071        bytes: &[u8],
4072        expected_coverage: &BlockRef,
4073    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
4074        let mut cursor = std::io::Cursor::new(bytes);
4075        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
4076            .with_fixint_encoding()
4077            .with_limit(bytes.len() as u64)
4078            .deserialize_from(&mut cursor)
4079            .map_err(|error| {
4080                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
4081            })?;
4082        if cursor.position() != bytes.len() as u64 {
4083            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4084                "runtime checkpoint has trailing bytes".to_owned(),
4085            ));
4086        }
4087        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
4088            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4089                format!(
4090                    "unsupported runtime checkpoint version {}",
4091                    checkpoint.version
4092                ),
4093            ));
4094        }
4095        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
4096
4097        let retained = self.config.journal_depth.min(checkpoint.journal.len());
4098        let discard = checkpoint.journal.len() - retained;
4099        checkpoint.journal.drain(..discard);
4100        Ok(DurableRuntimeRestorePlan {
4101            checkpoint: Some(checkpoint),
4102            fallback_history: Vec::new(),
4103        })
4104    }
4105
4106    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
4107        let Some(checkpoint) = plan.checkpoint else {
4108            self.journal = plan
4109                .fallback_history
4110                .into_iter()
4111                .map(|block| BlockJournal {
4112                    block,
4113                    inputs: Vec::new(),
4114                    applied: Vec::new(),
4115                    handler_ids: Vec::new(),
4116                    resynced: Vec::new(),
4117                    rollback_diffs: Vec::new(),
4118                })
4119                .collect();
4120            return;
4121        };
4122        self.safe_head = checkpoint.safe_head;
4123        self.finalized_head = checkpoint.finalized_head;
4124        self.health = checkpoint.health;
4125        self.pending_resyncs = checkpoint.pending_resyncs;
4126        self.coverage_head = checkpoint.coverage_head;
4127        self.journal = checkpoint
4128            .journal
4129            .into_iter()
4130            .map(|entry| BlockJournal {
4131                block: entry.block,
4132                inputs: Vec::new(),
4133                applied: Vec::new(),
4134                handler_ids: entry.handler_ids,
4135                resynced: Vec::new(),
4136                rollback_diffs: entry.rollback_diffs,
4137            })
4138            .collect();
4139        self.freshness = checkpoint.freshness;
4140        self.tracking = checkpoint.tracking;
4141        self.tracked_roots = checkpoint.tracked_roots;
4142        self.root_gate_cadence = checkpoint.root_gate_cadence;
4143        self.last_gate_block = checkpoint.last_gate_block;
4144        self.touched_since_gate = checkpoint.touched_since_gate;
4145        self.metrics.restore(checkpoint.metrics);
4146    }
4147
4148    fn validate_durable_runtime_checkpoint(
4149        &self,
4150        checkpoint: &DurableRuntimeCheckpoint,
4151        expected_coverage: &BlockRef,
4152    ) -> Result<(), ReactiveCheckpointRestoreError> {
4153        let invalid =
4154            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
4155        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
4156            return Err(invalid(
4157                "runtime checkpoint is missing its canonical coverage head".into(),
4158            ));
4159        };
4160        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
4161            return Err(invalid(format!(
4162                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
4163                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
4164            )));
4165        }
4166        for (label, head) in [
4167            ("safe", checkpoint.safe_head.as_ref()),
4168            ("finalized", checkpoint.finalized_head.as_ref()),
4169        ] {
4170            let Some(head) = head else { continue };
4171            if head.number > coverage.number
4172                || (head.number == coverage.number && head.hash != coverage.hash)
4173            {
4174                return Err(invalid(format!(
4175                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
4176                    head.number, head.hash, coverage.number, coverage.hash
4177                )));
4178            }
4179            if head.number.checked_add(1) == Some(coverage.number)
4180                && coverage
4181                    .parent_hash
4182                    .is_some_and(|parent| parent != head.hash)
4183            {
4184                return Err(invalid(format!(
4185                    "canonical coverage does not descend from adjacent {label} head"
4186                )));
4187            }
4188        }
4189        if let (Some(finalized), Some(safe)) = (
4190            checkpoint.finalized_head.as_ref(),
4191            checkpoint.safe_head.as_ref(),
4192        ) {
4193            if finalized.number > safe.number
4194                || (finalized.number == safe.number && finalized.hash != safe.hash)
4195            {
4196                return Err(invalid(
4197                    "finalized head is above or conflicts with the safe head".into(),
4198                ));
4199            }
4200            if finalized.number.checked_add(1) == Some(safe.number)
4201                && safe.parent_hash != Some(finalized.hash)
4202            {
4203                return Err(invalid(
4204                    "adjacent safe head does not descend from finalized head".into(),
4205                ));
4206            }
4207        }
4208
4209        let mut previous: Option<&DurableBlockJournal> = None;
4210        for entry in &checkpoint.journal {
4211            if entry.block.number > coverage.number
4212                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
4213            {
4214                return Err(invalid(format!(
4215                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
4216                    entry.block.number, entry.block.hash
4217                )));
4218            }
4219            if let Some(previous) = previous {
4220                if entry.block.number <= previous.block.number {
4221                    return Err(invalid(
4222                        "runtime journal block numbers are not strictly increasing".into(),
4223                    ));
4224                }
4225                if previous.block.number.checked_add(1) == Some(entry.block.number)
4226                    && entry.block.parent_hash.is_some()
4227                    && entry.block.parent_hash != Some(previous.block.hash)
4228                {
4229                    return Err(invalid(
4230                        "adjacent runtime journal blocks are not parent-linked".into(),
4231                    ));
4232                }
4233            }
4234            for (label, head) in [
4235                ("safe", checkpoint.safe_head.as_ref()),
4236                ("finalized", checkpoint.finalized_head.as_ref()),
4237            ] {
4238                if let Some(head) = head
4239                    && head.number == entry.block.number
4240                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
4241                {
4242                    return Err(invalid(format!(
4243                        "{label} head conflicts with the retained journal at block {}",
4244                        head.number
4245                    )));
4246                }
4247            }
4248            let mut handler_ids = HashSet::new();
4249            if entry
4250                .handler_ids
4251                .iter()
4252                .any(|handler_id| !handler_ids.insert(handler_id))
4253            {
4254                return Err(invalid(
4255                    "runtime journal contains duplicate handler generation ids".into(),
4256                ));
4257            }
4258            previous = Some(entry);
4259        }
4260        if let Some(tail) = checkpoint.journal.last()
4261            && tail.block.number == coverage.number
4262            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4263        {
4264            return Err(invalid(format!(
4265                "runtime journal tail conflicts with canonical coverage at block {}",
4266                coverage.number
4267            )));
4268        }
4269        if let Some(tail) = checkpoint.journal.last()
4270            && tail.block.number.checked_add(1) == Some(coverage.number)
4271            && coverage
4272                .parent_hash
4273                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4274        {
4275            return Err(invalid(format!(
4276                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4277                tail.block.number
4278            )));
4279        }
4280
4281        if let Some(last_gate_block) = checkpoint.last_gate_block {
4282            if last_gate_block > coverage.number {
4283                return Err(invalid(
4284                    "root-gate cursor lies beyond canonical coverage".into(),
4285                ));
4286            }
4287        } else if !checkpoint.tracked_roots.is_empty() {
4288            return Err(invalid(
4289                "root-gate baselines exist without a completed gate cursor".into(),
4290            ));
4291        }
4292        for (address, baseline) in &checkpoint.tracked_roots {
4293            let Some(policy) = checkpoint.tracking.get(address) else {
4294                return Err(invalid(
4295                    "root-gate baseline has no corresponding tracking policy".into(),
4296                ));
4297            };
4298            if matches!(policy, TrackingPolicy::Slots { .. }) {
4299                return Err(invalid(
4300                    "slot-only tracking cannot carry an account root baseline".into(),
4301                ));
4302            }
4303            if baseline.last_block > coverage.number
4304                || checkpoint
4305                    .last_gate_block
4306                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4307            {
4308                return Err(invalid(
4309                    "root-gate baseline lies beyond the committed gate window".into(),
4310                ));
4311            }
4312        }
4313        Ok(())
4314    }
4315
4316    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4317    ///
4318    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4319    /// gate as a no-op. Registering an account clears any baseline it held (a
4320    /// policy change re-adopts on the next probe rather than diffing against a
4321    /// baseline captured under the old policy). Each [`RootGateCadence`]
4322    /// firing, the gate
4323    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4324    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4325    /// account-proof seam and, on a move no decoder covered, emits a
4326    /// [`ReactiveReport::CoverageGap`] and schedules a
4327    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4328    /// accounts are never root-gated (spec Decision 3).
4329    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4330        self.tracking.insert(address, policy);
4331        self.tracked_roots.remove(&address);
4332    }
4333
4334    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4335    ///
4336    /// Returns `true` if the account was tracked.
4337    pub fn untrack_account(&mut self, address: Address) -> bool {
4338        self.tracked_roots.remove(&address);
4339        self.tracking.remove(&address).is_some()
4340    }
4341
4342    /// Set how often the root gate probes tracked accounts (default:
4343    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4344    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4345    ///
4346    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4347    /// accumulator and the last-fired block), so a stale window never leaks
4348    /// into the new cadence: the next canonical block fires the gate.
4349    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4350        self.root_gate_cadence = cadence;
4351        self.last_gate_block = None;
4352        self.touched_since_gate.clear();
4353    }
4354
4355    /// The configured [`RootGateCadence`].
4356    pub fn root_gate_cadence(&self) -> RootGateCadence {
4357        self.root_gate_cadence
4358    }
4359
4360    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4361    ///
4362    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4363    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4364    /// touched `(address, slot)` as
4365    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4366    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4367    /// re-verify) but ages to volatile once the clock passes `N`.
4368    ///
4369    /// Idempotent: if a registry is already installed it is left untouched, so an
4370    /// existing registry (and any stamps it holds) is never clobbered.
4371    pub fn enable_freshness_stamping(&mut self) {
4372        if self.freshness.is_none() {
4373            self.freshness = Some(FreshnessRegistry::new());
4374        }
4375    }
4376
4377    /// Borrow the runtime's freshness registry, if stamping was enabled.
4378    ///
4379    /// Returns `None` unless
4380    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4381    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4382        self.freshness.as_ref()
4383    }
4384
4385    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4386    ///
4387    /// Returns `None` unless
4388    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4389    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4390        self.freshness.as_mut()
4391    }
4392
4393    /// Return the current queryable [`CacheHealth`] of the runtime.
4394    pub fn health(&self) -> CacheHealth {
4395        self.health
4396    }
4397
4398    /// Return a point-in-time snapshot of the runtime's observability counters.
4399    pub fn metrics(&self) -> CacheMetricsSnapshot {
4400        self.metrics.snapshot()
4401    }
4402
4403    /// Complete the caller-driven self-heal by returning health to
4404    /// [`CacheHealth::Healthy`].
4405    ///
4406    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4407    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4408    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4409    /// has resynced or rebuilt the affected state, it invokes this to clear the
4410    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4411    /// called outside an ingest cycle.
4412    pub fn reset_health(&mut self) {
4413        self.health = CacheHealth::Healthy;
4414    }
4415
4416    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4417    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4418    /// state actually changes.
4419    ///
4420    /// The ladder is:
4421    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4422    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4423    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4424    ///
4425    /// A first event degrades; a second escalates to the terminal
4426    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4427    /// trust-loss paths (deep reorg beyond the journal and missed-range
4428    /// detection) so mixed event types climb the same ladder.
4429    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4430        let to = match self.health {
4431            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4432            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4433            CacheHealth::Unhealthy { .. } => return None,
4434        };
4435        self.transition_health(to, Some(block))
4436    }
4437
4438    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4439    /// when the state actually changes.
4440    ///
4441    /// The returned report must be threaded into the ingest cycle's dispatched
4442    /// reports so it reaches hooks and appears in
4443    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4444    /// current state (no transition, no report).
4445    fn transition_health(
4446        &mut self,
4447        to: CacheHealth,
4448        block: Option<u64>,
4449    ) -> Option<Arc<ReactiveReport<N>>> {
4450        if to == self.health {
4451            return None;
4452        }
4453        let from = self.health;
4454        self.health = to;
4455        Some(Arc::new(ReactiveReport::Health(HealthReport {
4456            from,
4457            to,
4458            block,
4459            _network: PhantomData,
4460        })))
4461    }
4462
4463    /// Register a handler.
4464    ///
4465    /// # Errors
4466    ///
4467    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4468    /// registered.
4469    pub fn register_handler(
4470        &mut self,
4471        handler: Arc<dyn ReactiveHandler<N>>,
4472    ) -> Result<(), RegisterError> {
4473        self.registry.register_handler(handler)
4474    }
4475
4476    /// Remove one handler from the runtime registry without resetting runtime state.
4477    ///
4478    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4479    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4480    /// tracking policy, freshness registry, or root-gate baselines, and it does
4481    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4482    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4483    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4484        self.registry.unregister_handler(id)
4485    }
4486
4487    /// Return true when the runtime has a registered handler with `id`.
4488    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4489        self.registry.contains_handler(id)
4490    }
4491
4492    /// Ids of all registered handlers, in registration (= routing) order.
4493    pub fn handler_ids(&self) -> Vec<HandlerId> {
4494        self.registry.handler_ids()
4495    }
4496
4497    /// Borrow the interests owned by one registered handler.
4498    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4499        self.registry.handler_interests(id)
4500    }
4501
4502    /// The most recently journaled canonical block, if any.
4503    ///
4504    /// This is the runtime's current chain position: the canonical block most
4505    /// recently recorded by ingestion. Reorged blocks are dropped from the
4506    /// journal during recovery, so a rolled-back head does not linger here.
4507    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4508    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4509    /// advance this coverage position across an empty event range. `None` until
4510    /// the first canonical input or barrier is accepted.
4511    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4512        self.coverage_head
4513    }
4514
4515    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4516    /// position without applying effects or dispatching reports.
4517    ///
4518    /// Handlers, hooks, tracking policy, and freshness configuration may be
4519    /// installed before adoption, but no chain input, finality, resync,
4520    /// root-gate observation, or health transition may have occurred. An exact
4521    /// repeat is idempotent; a different repeat and any active runtime fail
4522    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4523    /// and subscriber are available so chain identity and the cache's exact
4524    /// hash pin are validated too.
4525    ///
4526    /// # Errors
4527    ///
4528    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4529    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4530    /// different baseline has already been adopted.
4531    pub fn adopt_canonical_baseline(
4532        &mut self,
4533        baseline: BlockRef,
4534    ) -> Result<(), ReactiveBaselineError> {
4535        self.validate_canonical_baseline_adoption(baseline)?;
4536        if self.adopted_baseline_only().is_some() {
4537            return Ok(());
4538        }
4539
4540        self.coverage_head = Some(baseline);
4541        if self.config.journal_depth > 0 {
4542            self.journal.push_back(BlockJournal {
4543                block: baseline,
4544                inputs: Vec::new(),
4545                applied: Vec::new(),
4546                handler_ids: Vec::new(),
4547                resynced: Vec::new(),
4548                rollback_diffs: Vec::new(),
4549            });
4550        }
4551        Ok(())
4552    }
4553
4554    fn validate_canonical_baseline_adoption(
4555        &self,
4556        baseline: BlockRef,
4557    ) -> Result<(), ReactiveBaselineError> {
4558        if let Some(existing) = self.adopted_baseline_only() {
4559            return if existing == baseline {
4560                Ok(())
4561            } else {
4562                Err(ReactiveBaselineError::ConflictingBaseline {
4563                    existing_number: existing.number,
4564                    existing_hash: existing.hash,
4565                    requested_number: baseline.number,
4566                    requested_hash: baseline.hash,
4567                })
4568            };
4569        }
4570        if !self.is_pristine_for_checkpoint_restore() {
4571            return Err(ReactiveBaselineError::ActiveRuntime);
4572        }
4573        Ok(())
4574    }
4575
4576    /// Most recent safe head explicitly reported by the event source.
4577    pub const fn safe_head(&self) -> Option<&BlockRef> {
4578        self.safe_head.as_ref()
4579    }
4580
4581    /// Most recent finalized head explicitly reported by the event source.
4582    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4583        self.finalized_head.as_ref()
4584    }
4585
4586    /// Return whether the retained reorg journal still contains an applied
4587    /// record for `handler_id`.
4588    ///
4589    /// The record is retained even when the handler emitted only resync work,
4590    /// so an owner can keep an explicit cache-eviction fence active for exactly
4591    /// as long as a later rollback could restore effects from that handler
4592    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4593    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4594        self.journal
4595            .iter()
4596            .any(|entry| entry.handler_ids.contains(handler_id))
4597    }
4598
4599    /// Return the distinct handler generations represented in the retained
4600    /// reorg journal.
4601    ///
4602    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4603    /// large set of cache-eviction fences without rescanning the journal for
4604    /// every handler.
4605    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4606        self.journal
4607            .iter()
4608            .flat_map(|entry| entry.handler_ids.iter().cloned())
4609            .collect()
4610    }
4611
4612    /// Queued resync requests: surfaced by handlers but not yet executed by an
4613    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4614    ///
4615    /// Callers driving resync execution themselves (plain
4616    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4617    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4618    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4619    /// generation-owned work, while
4620    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4621    /// for exclusively torn-down accounts.
4622    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4623        &self.pending_resyncs
4624    }
4625
4626    /// Cancel every queued request with the exact logical `id`.
4627    ///
4628    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4629    /// removes whole requests and never touches other work merely because it
4630    /// targets the same account. It is therefore the safe primitive for
4631    /// generation-scoped owner teardown when the caller maintains an
4632    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4633    /// an earlier batch report cannot be recalled.
4634    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4635        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4636    }
4637
4638    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4639    ///
4640    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4641    /// pending-queue order, independent of caller id order. This is the batch
4642    /// teardown primitive for owners that can have many pending repairs; it
4643    /// avoids rescanning the complete pending queue once per owned id.
4644    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4645        if ids.is_empty() {
4646            return Vec::new();
4647        }
4648        let ids: HashSet<&ResyncId> = ids.iter().collect();
4649        let mut cancelled = Vec::new();
4650        self.pending_resyncs.retain(|request| {
4651            if ids.contains(&request.id) {
4652                cancelled.push(request.clone());
4653                false
4654            } else {
4655                true
4656            }
4657        });
4658        cancelled
4659    }
4660
4661    /// Cancel queued resync work that targets `address`, returning the
4662    /// cancelled portions.
4663    ///
4664    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4665    /// a request reduced to zero targets is dropped entirely, while
4666    /// mixed-target requests keep their other accounts queued. Each returned
4667    /// request mirrors the original id/reason/block/priority and carries only
4668    /// the targets that were cancelled.
4669    ///
4670    /// This is appropriate only when the caller owns the complete account. For
4671    /// a pool sharing a vault or emitter with other owners, cancel its exact
4672    /// request IDs through
4673    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4674    /// recall requests already returned to the caller in earlier batch reports.
4675    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4676        let mut cancelled = Vec::new();
4677        self.pending_resyncs.retain_mut(|request| {
4678            let (matching, remaining): (Vec<_>, Vec<_>) = request
4679                .targets
4680                .drain(..)
4681                .partition(|target| resync_target_address(target) == address);
4682            request.targets = remaining;
4683            if !matching.is_empty() {
4684                cancelled.push(ResyncRequest {
4685                    id: request.id.clone(),
4686                    reason: request.reason.clone(),
4687                    block: request.block.clone(),
4688                    targets: matching,
4689                    priority: request.priority,
4690                });
4691            }
4692            !request.targets.is_empty()
4693        });
4694        cancelled
4695    }
4696
4697    /// Register a hook.
4698    ///
4699    /// # Errors
4700    ///
4701    /// This implementation is currently infallible; the `Result` preserves the
4702    /// registration contract for future hook validation.
4703    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4704        self.hooks.push(hook);
4705        Ok(())
4706    }
4707
4708    /// Return all registered interests in handler registration order.
4709    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4710        self.registry.interests()
4711    }
4712
4713    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4714    ///
4715    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4716    /// restored before the error returns, and hooks see no reports. Monotonic
4717    /// observability counters still retain rejected-attempt signals.
4718    /// The current rollback guard snapshots complete mutable cache state once per
4719    /// batch, so callers should preserve transport batching rather than splitting
4720    /// one delivery into many one-record calls.
4721    ///
4722    /// # Errors
4723    ///
4724    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4725    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4726    /// be applied. Cache and canonical runtime state are restored before return.
4727    pub fn ingest_batch(
4728        &mut self,
4729        cache: &mut EvmCache,
4730        batch: ReactiveInputBatch<N>,
4731    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4732        let preconfirmation = batch_preconfirmation(&batch)?;
4733        if let Some(flashblock) = preconfirmation.as_ref() {
4734            self.prepare_preconfirmed_branch(cache, flashblock)?;
4735        } else {
4736            self.discard_preconfirmed_branch(cache);
4737        }
4738        let cache_state = EvmCacheStateSnapshot::capture(cache);
4739        let runtime_state = self.checkpoint_state();
4740        let batch_report = match self.ingest_batch_direct(cache, batch) {
4741            Ok(report) => report,
4742            Err(error) => {
4743                cache_state.restore(cache);
4744                self.restore_transaction_state(runtime_state);
4745                return Err(error);
4746            }
4747        };
4748        if let Some(flashblock) = preconfirmation {
4749            self.restore_transaction_state(runtime_state);
4750            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4751                branch.flashblock = flashblock;
4752            }
4753        }
4754        self.dispatch_reports(&batch_report.reports);
4755        let _ = &self.config;
4756        Ok(batch_report)
4757    }
4758
4759    /// Ingest a batch, then execute surfaced storage resync requests.
4760    ///
4761    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
4762    /// direct handler effects, then runs a synchronous resync phase over the
4763    /// collected [`ResyncRequest`]s. Storage targets are fetched through
4764    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
4765    /// values are applied as [`StateUpdate::slot`] updates through
4766    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
4767    /// in [`ResyncReport::failed`]. It does not start subscribers, background
4768    /// workers, or network transport.
4769    ///
4770    /// # Errors
4771    ///
4772    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
4773    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
4774    /// resync targets are reported in the successful batch report instead.
4775    pub fn ingest_batch_with_resync(
4776        &mut self,
4777        cache: &mut EvmCache,
4778        batch: ReactiveInputBatch<N>,
4779    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4780        let preconfirmation = batch_preconfirmation(&batch)?;
4781        if let Some(flashblock) = preconfirmation.as_ref() {
4782            self.prepare_preconfirmed_branch(cache, flashblock)?;
4783        } else {
4784            self.discard_preconfirmed_branch(cache);
4785        }
4786        let cache_state = EvmCacheStateSnapshot::capture(cache);
4787        let runtime_state = self.checkpoint_state();
4788        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
4789            Ok(report) => report,
4790            Err(error) => {
4791                cache_state.restore(cache);
4792                self.restore_transaction_state(runtime_state);
4793                return Err(error);
4794            }
4795        };
4796
4797        if let Some(flashblock) = preconfirmation {
4798            self.restore_transaction_state(runtime_state);
4799            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4800                branch.flashblock = flashblock;
4801            }
4802        }
4803
4804        self.dispatch_reports(&batch_report.reports);
4805        let _ = &self.config;
4806        Ok(batch_report)
4807    }
4808
4809    /// Active speculative Flashblock snapshot, when the cache currently
4810    /// includes pre-confirmed effects.
4811    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
4812        self.preconfirmed_branch
4813            .as_ref()
4814            .map(|branch| &branch.flashblock)
4815    }
4816
4817    /// Restore the cache to its canonical state and discard any speculative
4818    /// Flashblock effects.
4819    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
4820        self.discard_preconfirmed_branch(cache);
4821    }
4822
4823    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
4824        if let Some(branch) = self.preconfirmed_branch.take() {
4825            branch.canonical_cache.restore(cache);
4826        }
4827    }
4828
4829    fn prepare_preconfirmed_branch(
4830        &mut self,
4831        cache: &mut EvmCache,
4832        incoming: &FlashblockRef,
4833    ) -> Result<(), ReactiveError> {
4834        if let Some(active) = self.preconfirmed_branch.as_ref()
4835            && active.flashblock.same_payload(incoming)
4836        {
4837            if let (Some(active_index), Some(incoming_index)) =
4838                (active.flashblock.index, incoming.index)
4839                && incoming_index < active_index
4840            {
4841                return Err(ReactiveError::InvalidInputRecord {
4842                    message: format!(
4843                        "Flashblock index regressed from {active_index} to {incoming_index}"
4844                    ),
4845                });
4846            }
4847            if active.flashblock.index.is_some()
4848                && active.flashblock.index == incoming.index
4849                && active.flashblock.content_hash != incoming.content_hash
4850            {
4851                self.discard_preconfirmed_branch(cache);
4852                return Err(ReactiveError::InvalidInputRecord {
4853                    message: "same Flashblock payload/index carried conflicting cumulative content"
4854                        .into(),
4855                });
4856            }
4857            install_preconfirmed_cache_context(cache, incoming);
4858            return Ok(());
4859        }
4860
4861        self.discard_preconfirmed_branch(cache);
4862        self.preconfirmed_branch = Some(PreconfirmedBranch {
4863            flashblock: incoming.clone(),
4864            canonical_cache: EvmCacheStateSnapshot::capture(cache),
4865        });
4866        install_preconfirmed_cache_context(cache, incoming);
4867        Ok(())
4868    }
4869
4870    fn ingest_batch_with_resync_direct(
4871        &mut self,
4872        cache: &mut EvmCache,
4873        batch: ReactiveInputBatch<N>,
4874    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4875        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
4876        if !batch_report.resyncs.is_empty() {
4877            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
4878            // Count unique logical requests: several handlers may emit the same
4879            // ResyncId in one batch, and duplicates fan out per-origin in the
4880            // report but are one unit of resync work for the metric.
4881            let unique_requests = resync_report
4882                .requested
4883                .iter()
4884                .map(|request| &request.id)
4885                .collect::<HashSet<_>>()
4886                .len();
4887            self.metrics
4888                .resync_requests
4889                .fetch_add(unique_requests as u64, Ordering::Relaxed);
4890            self.metrics
4891                .resync_failures
4892                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
4893            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
4894            self.record_journal_resync(&resync_report);
4895            batch_report
4896                .reports
4897                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
4898        }
4899        Ok(batch_report)
4900    }
4901
4902    fn ingest_batch_direct(
4903        &mut self,
4904        cache: &mut EvmCache,
4905        batch: ReactiveInputBatch<N>,
4906    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4907        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
4908        if let Some(chain_id) = batch_chain_id
4909            && chain_id != cache.chain_id()
4910        {
4911            return Err(ReactiveError::InvalidInputRecord {
4912                message: format!(
4913                    "batch chain id {chain_id} does not match cache chain id {}",
4914                    cache.chain_id()
4915                ),
4916            });
4917        }
4918        if !chain_controls.is_empty() && batch_chain_id.is_none() {
4919            return Err(ReactiveError::InvalidChainControl {
4920                message: "chain-control batches require an authoritative batch chain id".into(),
4921            });
4922        }
4923        for (record, _, _) in &records {
4924            record.validated_identity()?;
4925            if let Some(chain_id) = record.context.chain_id
4926                && chain_id != cache.chain_id()
4927            {
4928                return Err(ReactiveError::InvalidInputRecord {
4929                    message: format!(
4930                        "input chain id {chain_id} does not match cache chain id {}",
4931                        cache.chain_id()
4932                    ),
4933                });
4934            }
4935        }
4936        let records = sort_scoped_records(dedupe_scoped_records(records)?);
4937
4938        let mut batch_report = ReactiveBatchReport::default();
4939        let mut reports_to_dispatch = Vec::new();
4940        let control_split = validate_control_phase_order(&chain_controls)?;
4941        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
4942        let pre_record_state =
4943            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
4944        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
4945        let mut batch_dropped = BatchDroppedCanonical::default();
4946        for control in pre_record_controls {
4947            if let ChainControl::Reorg {
4948                common_ancestor,
4949                old_tip,
4950                ..
4951            } = control
4952            {
4953                batch_dropped.record_explicit(common_ancestor, old_tip);
4954                let drained = self
4955                    .journal
4956                    .iter()
4957                    .filter(|entry| entry.block.number > common_ancestor.number)
4958                    .map(|entry| entry.block)
4959                    .collect::<Vec<_>>();
4960                batch_dropped.record_drained(&drained);
4961            }
4962        }
4963        let certified_progress_through = post_record_controls
4964            .iter()
4965            .filter_map(canonical_coverage_control_block)
4966            .map(|block| block.number)
4967            .max();
4968        for control in pre_record_controls.iter().cloned() {
4969            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
4970        }
4971        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
4972        // batch (union of applied `StateDiff` addresses) and the batch's canonical
4973        // block number, so the per-block root gate can run once after the record
4974        // loop with the full touched set.
4975        let mut touched_addrs: HashSet<Address> = HashSet::new();
4976        let mut canonical_batch_block: Option<u64> = None;
4977
4978        for (record, audience, delivery_scope) in records {
4979            let raw_canonical_block = canonical_record_block(&record).copied();
4980            let canonical_block = raw_canonical_block.map(|block| {
4981                pre_record_state
4982                    .resolved_canonical_blocks
4983                    .get(&(block.number, block.hash))
4984                    .copied()
4985                    .unwrap_or(block)
4986            });
4987            let input_ref = record.input_ref();
4988            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
4989                input_ref,
4990                context: record.context.clone(),
4991                provider: record.provider.clone(),
4992                _network: PhantomData,
4993            })));
4994
4995            let recovered_reorg = if delivery_scope.advances_canonical_state() {
4996                if let Some(block) = canonical_block.as_ref() {
4997                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
4998                        && certified_progress_through
4999                            .is_some_and(|through| block.number <= through);
5000                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
5001                        raw.parent_hash.is_none()
5002                            && batch_dropped.covers_implicit_number(raw.number)
5003                    });
5004                    self.recover_for_canonical_input(
5005                        cache,
5006                        block,
5007                        gap_is_certified,
5008                        parentless_replacement_is_proven,
5009                        &mut reports_to_dispatch,
5010                    )
5011                } else {
5012                    None
5013                }
5014            } else {
5015                None
5016            };
5017            let recovered_reorg_for_input = recovered_reorg.is_some();
5018            if let Some(reorg_report) = recovered_reorg {
5019                self.metrics
5020                    .reorgs_recovered
5021                    .fetch_add(1, Ordering::Relaxed);
5022                remove_canceled_resyncs_from_batch(
5023                    &mut batch_report.resyncs,
5024                    &reorg_report.canceled_resyncs,
5025                );
5026                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5027            }
5028
5029            // Removed/reorged records are lifecycle signals, never handler
5030            // data. Canonical scopes may roll back state; owner-only catch-up
5031            // scopes deliberately cannot, but both must suppress ordinary
5032            // decoding even when the referenced block is unknown, aged out of
5033            // the journal, or has already been removed once.
5034            if reorg_signal_block(&record).is_some() {
5035                if delivery_scope.advances_canonical_state()
5036                    && let Some(reorg_report) = self.recover_for_reorged_input(
5037                        cache,
5038                        &record,
5039                        &mut batch_dropped,
5040                        &mut reports_to_dispatch,
5041                    )
5042                {
5043                    self.metrics
5044                        .reorgs_recovered
5045                        .fetch_add(1, Ordering::Relaxed);
5046                    remove_canceled_resyncs_from_batch(
5047                        &mut batch_report.resyncs,
5048                        &reorg_report.canceled_resyncs,
5049                    );
5050                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5051                }
5052                continue;
5053            }
5054
5055            // Preflight validates owner history against the journal state at
5056            // batch entry. A canonical record earlier in this same transaction
5057            // may legitimately replace and drain that block, so close the
5058            // resulting TOCTOU window immediately before any owner handler can
5059            // mutate the cache. The outer transaction guard restores every
5060            // earlier record in the batch on failure.
5061            if delivery_scope == DeliveryScope::OwnerCatchup {
5062                self.validate_owner_catchup_record_against_current_journal(&record)?;
5063            }
5064
5065            if delivery_scope.advances_canonical_state()
5066                && let Some(block) = canonical_block.as_ref()
5067            {
5068                // Phase-8 step 4: remember the batch's canonical block (the last
5069                // canonical record wins) so the root gate probes at that height.
5070                canonical_batch_block = Some(block.number);
5071                self.record_journal_input(block, input_ref);
5072            }
5073
5074            // Keep every lazy provider read pinned to the exact event block
5075            // before handlers run. A full header installs the complete EVM env;
5076            // compact log-only progress installs NUMBER/timestamp and clears
5077            // unknown header-only fields. A later record for the same retained
5078            // canonical block can preserve an already-installed full env.
5079            if delivery_scope.advances_canonical_state()
5080                && let Some(block) = canonical_block.as_ref()
5081            {
5082                match advance_block_for_canonical_record(cache, &record) {
5083                    Some(Ok(())) => {
5084                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
5085                    }
5086                    Some(Err(err)) => {
5087                        cache.advance_compact_block(
5088                            block.number,
5089                            block.hash,
5090                            block.timestamp,
5091                            false,
5092                        );
5093                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
5094                            ReactiveErrorReport {
5095                                input_ref: Some(input_ref),
5096                                message: err.to_string(),
5097                                _network: PhantomData,
5098                            },
5099                        )));
5100                    }
5101                    None => cache.advance_compact_block(
5102                        block.number,
5103                        block.hash,
5104                        block.timestamp,
5105                        !recovered_reorg_for_input,
5106                    ),
5107                }
5108            }
5109
5110            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
5111            if executions.is_empty() {
5112                continue;
5113            }
5114
5115            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
5116                input_ref,
5117                handler_ids: executions
5118                    .iter()
5119                    .map(|execution| execution.handler_id.clone())
5120                    .collect(),
5121                _network: PhantomData,
5122            })));
5123
5124            detect_conflicts(input_ref, &executions)?;
5125
5126            // Phase-8 step 3: canonical block number for freshness stamping.
5127            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
5128            // can be used while `self.freshness_mut()` mutably borrows `self`
5129            // inside the execution loop. `None` for pending/removed/reorged
5130            // records — those never stamp canonical freshness.
5131            let canonical_block_number = delivery_scope
5132                .advances_canonical_state()
5133                .then_some(canonical_block)
5134                .flatten()
5135                .map(|block| block.number);
5136
5137            for execution in executions {
5138                let diff = if execution.state_updates.is_empty() {
5139                    StateDiff::default()
5140                } else {
5141                    cache.apply_updates(&execution.state_updates)
5142                };
5143
5144                batch_report
5145                    .resyncs
5146                    .extend(execution.resyncs.iter().cloned());
5147                self.pending_resyncs
5148                    .extend(execution.resyncs.iter().cloned());
5149                batch_report
5150                    .speculative
5151                    .extend(execution.speculative.iter().cloned());
5152
5153                let applied = AppliedReport {
5154                    input_ref,
5155                    handler_id: execution.handler_id,
5156                    quality: execution.quality,
5157                    tags: execution.tags,
5158                    diff,
5159                    state_updates: execution.state_updates,
5160                    invalidations: execution.invalidations,
5161                    resyncs: execution.resyncs,
5162                    speculative: execution.speculative,
5163                    hook_signals: execution.hook_signals,
5164                    _network: PhantomData,
5165                };
5166                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
5167                // from this canonical handler write as `ValidThrough(N)`, so an
5168                // event-maintained slot stops being re-verified until the clock
5169                // passes its write block. Read the changed slots straight off
5170                // `applied.diff` (which borrows the local, not `self`) and stamp
5171                // via `self.freshness`, done before `applied` is moved into the
5172                // journal/batch below. Only genuinely-changed slots appear here,
5173                // since a no-op re-write records no `SlotChange`.
5174                if let (Some(number), Some(registry)) =
5175                    (canonical_block_number, self.freshness.as_mut())
5176                {
5177                    for change in &applied.diff.slots {
5178                        registry.valid_through_slot(change.address, change.slot, number);
5179                    }
5180                }
5181
5182                // Phase-8 step 4: record every address this decoder actually wrote
5183                // (or attempted to write) so the root gate can tell a
5184                // decoder-covered root move from an uncovered coverage gap. Fold in
5185                // the full `StateDiff` address footprint — real changes
5186                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
5187                // a decoder that tried to write a cold slot still counts as
5188                // covering the account.
5189                if delivery_scope.advances_canonical_state() {
5190                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
5191                }
5192
5193                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
5194                reports_to_dispatch.push(report);
5195                if let Some(block) = canonical_block.as_ref() {
5196                    if delivery_scope.advances_canonical_state() {
5197                        self.record_journal_applied(block, applied.clone());
5198                    } else {
5199                        self.record_journal_applied_if_present(block, applied.clone());
5200                    }
5201                }
5202                batch_report.applied.push(applied);
5203            }
5204        }
5205
5206        // Coverage/finality controls certify the records that precede them.
5207        // Applying them here also leaves the live cache pinned to a certified
5208        // zero-event tail rather than the last block that happened to emit a
5209        // matching log. Reorg controls were applied before the record loop.
5210        for control in post_record_controls.iter().cloned() {
5211            if let Some(block) = canonical_coverage_control_block(&control) {
5212                canonical_batch_block = Some(
5213                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
5214                );
5215            }
5216            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5217        }
5218
5219        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
5220        // addresses (after all handler effects, so the set is complete), then
5221        // fire the root gate only on cadence boundaries. The gate diffs
5222        // against persisted baselines, so skipped blocks lose no detection —
5223        // but the touched set must be the union since the last firing, or a
5224        // decoder-covered write in a skipped block would false-positive as a
5225        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
5226        // callers see them and `ingest_batch_with_resync` executes them) and
5227        // coverage reports go into the dispatched reports.
5228        if self.root_gate_runnable(cache) {
5229            self.touched_since_gate
5230                .extend(touched_addrs.iter().copied());
5231            if self.root_gate_due(canonical_batch_block) {
5232                let accumulated = std::mem::take(&mut self.touched_since_gate);
5233                self.run_root_gate(
5234                    cache,
5235                    canonical_batch_block,
5236                    &accumulated,
5237                    &mut batch_report.resyncs,
5238                    &mut reports_to_dispatch,
5239                );
5240                self.last_gate_block = canonical_batch_block;
5241            }
5242        } else {
5243            // A gate that cannot run (disabled, nothing root-gated, or no
5244            // proof fetcher) must not grow the accumulator unboundedly.
5245            // Dropping it is safe: without a runnable gate no baselines exist
5246            // (a fetcher cannot be uninstalled, and untracking drops the
5247            // baseline), so there is nothing a lost touched set could falsely
5248            // gap against later.
5249            self.touched_since_gate.clear();
5250        }
5251
5252        batch_report.reports = reports_to_dispatch;
5253        Ok(batch_report)
5254    }
5255
5256    /// Prove that every owner-only historical effect can be attached to an
5257    /// compatible retained canonical journal entry before any chain control or
5258    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5259    /// optional enrichment, but two present values must agree; this matches the
5260    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5261    ///
5262    /// Owner catch-up deliberately does not advance canonical coverage. Its
5263    /// effects are appended to the already-existing journal entry so a later
5264    /// reorg can roll them back with the rest of that block. Accepting a block
5265    /// outside the journal would make the cache mutation irreversible. A reorg
5266    /// control in the same batch also invalidates entries above its ancestor,
5267    /// so those entries are rejected even though they still exist at this
5268    /// preflight point.
5269    fn validate_owner_catchup_against_journal(
5270        &self,
5271        control_state: &ChainControlState,
5272        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5273    ) -> Result<(), ReactiveError> {
5274        for (record, _, delivery_scope) in records {
5275            if *delivery_scope != DeliveryScope::OwnerCatchup {
5276                continue;
5277            }
5278            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5279            // cannot make them canonical and the record loop deliberately skips
5280            // handler execution, so there is no effect that needs attaching to
5281            // a rollback journal entry.
5282            if reorg_signal_block(record).is_some() {
5283                continue;
5284            }
5285            let context_block = canonical_record_block(record).ok_or_else(|| {
5286                ReactiveError::InvalidChainControl {
5287                    message: "owner catch-up input has no canonical block identity".into(),
5288                }
5289            })?;
5290            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5291            let invalidated_by_control = control_state
5292                .journal_invalidated_from
5293                .is_some_and(|from| block.number >= from);
5294            let rollbackable = !invalidated_by_control
5295                && self.journal.iter().any(|entry| {
5296                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5297                });
5298            if !rollbackable {
5299                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5300                    number: block.number,
5301                    hash: block.hash,
5302                });
5303            }
5304        }
5305        Ok(())
5306    }
5307
5308    fn validate_owner_catchup_record_against_current_journal(
5309        &self,
5310        record: &ReactiveInputRecord<N>,
5311    ) -> Result<(), ReactiveError> {
5312        let context_block =
5313            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5314                message: "owner catch-up input has no canonical block identity".into(),
5315            })?;
5316        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5317        if self
5318            .journal
5319            .iter()
5320            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5321        {
5322            return Ok(());
5323        }
5324        Err(ReactiveError::OwnerCatchupOutsideJournal {
5325            number: block.number,
5326            hash: block.hash,
5327        })
5328    }
5329
5330    /// Whether the root gate could produce any signal at all: some tracked
5331    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5332    /// When this is false the touched accumulator is dropped rather than
5333    /// grown (see the ingest call site for why that is safe).
5334    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5335        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5336            return false;
5337        }
5338        let has_gated_targets = self
5339            .tracking
5340            .values()
5341            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5342        has_gated_targets && cache.account_proof_fetcher().is_some()
5343    }
5344
5345    /// Whether the root gate is due at this batch's canonical block (§6.2):
5346    /// the first canonical block ever seen always fires (baseline adoption
5347    /// must not wait a full window), then at most once every `n` blocks.
5348    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5349        let Some(block) = canonical_block else {
5350            return false;
5351        };
5352        match self.root_gate_cadence {
5353            RootGateCadence::Disabled => false,
5354            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5355                None => true,
5356                Some(last) => block >= last.saturating_add(n.get()),
5357            },
5358        }
5359    }
5360
5361    /// The `storageHash` root gate (Phase-8 step 4), fired per
5362    /// [`RootGateCadence`] window (§6.2).
5363    ///
5364    /// Runs at the firing batch's canonical block, with `touched` carrying the
5365    /// union of decoder-touched addresses since the previous firing. For each tracked
5366    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5367    /// account, probe the root (and account fields) via the account-proof seam and
5368    /// apply the spec §4 table:
5369    ///
5370    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5371    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5372    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5373    ///   ⇒ a decoder covered it; re-adopt, no gap.
5374    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5375    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5376    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5377    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5378    ///   the baseline (native field changes never move the storage root); on a move
5379    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5380    ///   resync for the changed fields and re-adopt.
5381    ///
5382    /// No-op when the tracking registry is empty, when the batch has no canonical
5383    /// block, or when the cache has no account-proof fetcher installed.
5384    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5385    /// Decision 3).
5386    fn run_root_gate(
5387        &mut self,
5388        cache: &EvmCache,
5389        canonical_block: Option<u64>,
5390        touched: &HashSet<Address>,
5391        resyncs: &mut Vec<ResyncRequest>,
5392        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5393    ) {
5394        if self.tracking.is_empty() {
5395            return;
5396        }
5397        let Some(block) = canonical_block else {
5398            return;
5399        };
5400        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5401            return;
5402        };
5403
5404        // Collect the root-gated targets (Slots opts out) in a stable order so a
5405        // single-block sequence of resyncs/reports is deterministic.
5406        let mut targets: Vec<(Address, bool)> = self
5407            .tracking
5408            .iter()
5409            .filter_map(|(address, policy)| match policy {
5410                TrackingPolicy::Slots { .. } => None,
5411                TrackingPolicy::WholeAccount => Some((*address, true)),
5412                TrackingPolicy::Scalars => Some((*address, false)),
5413            })
5414            .collect();
5415        if targets.is_empty() {
5416            return;
5417        }
5418        targets.sort_by_key(|(address, _)| *address);
5419
5420        let block_id = BlockId::number(block);
5421        // ONE seam invocation carries every root-gated target (root-only
5422        // probes: no storage keys needed). eth_getProof is single-address at
5423        // the RPC level, so batching here lets the fetcher fan the requests
5424        // out concurrently instead of paying N sequential round trips.
5425        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5426            targets
5427                .iter()
5428                .map(|&(address, _)| (address, vec![]))
5429                .collect(),
5430            block_id,
5431        )
5432        .into_iter()
5433        .collect();
5434        for (address, whole_account) in targets {
5435            let Some(Ok(proof)) = probes.remove(&address) else {
5436                // A failed/omitted probe carries no signal; leave the baseline
5437                // untouched and try again next block.
5438                continue;
5439            };
5440
5441            let baseline = self.tracked_roots.get(&address).cloned();
5442            let Some(baseline) = baseline else {
5443                // First observation: adopt the baseline. Not a coverage gap.
5444                self.adopt_root(address, block, &proof);
5445                continue;
5446            };
5447
5448            // A stale probe (a batch whose canonical block is not newer than the
5449            // last one we baselined this account against) carries no forward
5450            // signal: skip it rather than diff against — or clobber — a newer
5451            // baseline.
5452            if block <= baseline.last_block {
5453                continue;
5454            }
5455
5456            if whole_account {
5457                if proof.storage_hash == baseline.last_root {
5458                    // Tight steady-state path: unchanged root ⇒ nothing.
5459                    continue;
5460                }
5461                // Root moved.
5462                if !touched.contains(&address) {
5463                    // Moved with no covering decoder — the coverage gap.
5464                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5465                        address,
5466                        block,
5467                        _network: PhantomData,
5468                    })));
5469                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5470                    resyncs.push(root_moved_account_resync(
5471                        address,
5472                        block,
5473                        AccountFieldMask {
5474                            balance: true,
5475                            nonce: true,
5476                            code: true,
5477                        },
5478                    ));
5479                }
5480                // Adopt the new root whether or not a decoder covered it.
5481                self.adopt_root(address, block, &proof);
5482            } else {
5483                // Scalars: compare the account fields directly (native changes do
5484                // not move the storage root).
5485                let balance_moved = proof.balance != baseline.balance;
5486                let nonce_moved = proof.nonce != baseline.nonce;
5487                let code_moved = proof.code_hash != baseline.code_hash;
5488                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5489                    resyncs.push(root_moved_account_resync(
5490                        address,
5491                        block,
5492                        AccountFieldMask {
5493                            balance: balance_moved,
5494                            nonce: nonce_moved,
5495                            code: code_moved,
5496                        },
5497                    ));
5498                }
5499                self.adopt_root(address, block, &proof);
5500            }
5501        }
5502    }
5503
5504    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5505    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5506        self.tracked_roots.insert(
5507            address,
5508            TrackedRoot {
5509                last_root: proof.storage_hash,
5510                last_block: block,
5511                balance: proof.balance,
5512                nonce: proof.nonce,
5513                code_hash: proof.code_hash,
5514            },
5515        );
5516    }
5517
5518    fn execute_handlers(
5519        &self,
5520        cache: &EvmCache,
5521        record: &ReactiveInputRecord<N>,
5522        input_ref: InputRef,
5523        audience: &DeliveryAudience,
5524    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5525        let mut executions = Vec::new();
5526        let candidates: Vec<_> = match &record.input {
5527            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5528            ReactiveInput::BlockHeader(_)
5529            | ReactiveInput::FullBlock(_)
5530            | ReactiveInput::PendingTxHash(_)
5531            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5532        };
5533        for registered in candidates {
5534            match audience {
5535                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5536                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5537                    continue;
5538                }
5539                DeliveryAudience::All
5540                | DeliveryAudience::Owners(_)
5541                | DeliveryAudience::AllExcept(_) => {}
5542            }
5543            if !registered.matches(&record.input) {
5544                continue;
5545            }
5546
5547            let outcome = registered
5548                .handler
5549                .handle(&record.context, &record.input, cache)
5550                .map_err(|source| ReactiveError::HandlerFailed {
5551                    handler_id: registered.id.clone(),
5552                    source,
5553                })?;
5554
5555            if let Err(error) =
5556                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5557            {
5558                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5559                    self.metrics
5560                        .pending_contamination
5561                        .fetch_add(1, Ordering::Relaxed);
5562                }
5563                return Err(error);
5564            }
5565            executions.push(HandlerExecution::from_outcome(
5566                registered.id.clone(),
5567                input_ref,
5568                outcome,
5569                matches!(
5570                    record.context.chain_status,
5571                    ChainStatus::Preconfirmed { .. }
5572                ),
5573            ));
5574        }
5575        Ok(executions)
5576    }
5577
5578    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5579        for report in reports {
5580            for hook in &self.hooks {
5581                hook.on_report(report.clone());
5582            }
5583        }
5584    }
5585
5586    fn apply_chain_control(
5587        &mut self,
5588        cache: &mut EvmCache,
5589        control: ChainControl,
5590        batch_report: &mut ReactiveBatchReport<N>,
5591        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5592    ) {
5593        match &control {
5594            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5595            ChainControl::Finalized(block) => {
5596                set_or_enrich_block_ref(&mut self.finalized_head, block);
5597            }
5598            ChainControl::CanonicalProgress(block)
5599            | ChainControl::Barrier {
5600                block: Some(block), ..
5601            } => {
5602                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5603                    optional_block_refs_are_compatible(Some(current), Some(block))
5604                });
5605                cache.advance_compact_block(
5606                    block.number,
5607                    block.hash,
5608                    block.timestamp,
5609                    preserve_env,
5610                );
5611                advance_or_enrich_coverage(&mut self.coverage_head, block);
5612                let enriched = self.journal_entry_mut(block).block;
5613                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5614                self.trim_journal();
5615            }
5616            ChainControl::Barrier { block: None, .. } => {}
5617            ChainControl::Reorg {
5618                common_ancestor,
5619                old_tip,
5620                ..
5621            } => {
5622                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5623                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5624                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5625                    entry.block.number == common_ancestor.number
5626                        && entry.block.hash == common_ancestor.hash
5627                }) {
5628                    self.drain_journal_after(ancestor_index)
5629                } else {
5630                    // Sparse journals are expected for blocks with no matching
5631                    // events. If the oldest retained entry is at or below the
5632                    // ancestor, every effect above it is still present and the
5633                    // rollback is complete even without an exact anchor.
5634                    if self
5635                        .journal
5636                        .front()
5637                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5638                    {
5639                        reports.extend(
5640                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5641                        );
5642                    }
5643                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5644                };
5645
5646                let reorg_report = self
5647                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5648                    .unwrap_or_else(|| ReorgReport {
5649                        dropped: Some(*old_tip),
5650                        dropped_blocks: Vec::new(),
5651                        dropped_inputs: Vec::new(),
5652                        rollback_updates: Vec::new(),
5653                        rollback_diff: StateDiff::default(),
5654                        purge_updates: Vec::new(),
5655                        purge_diff: StateDiff::default(),
5656                        canceled_resyncs: self
5657                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5658                        reason: ReorgReason::Explicit,
5659                        _network: PhantomData,
5660                    });
5661                remove_canceled_resyncs_from_batch(
5662                    &mut batch_report.resyncs,
5663                    &reorg_report.canceled_resyncs,
5664                );
5665                self.metrics
5666                    .reorgs_recovered
5667                    .fetch_add(1, Ordering::Relaxed);
5668                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5669
5670                if self.safe_head.as_ref().is_some_and(|head| {
5671                    head.number > common_ancestor.number
5672                        || (head.number == common_ancestor.number
5673                            && head.hash != common_ancestor.hash)
5674                }) {
5675                    self.safe_head = None;
5676                }
5677                if self.finalized_head.as_ref().is_some_and(|head| {
5678                    head.number > common_ancestor.number
5679                        || (head.number == common_ancestor.number
5680                            && head.hash != common_ancestor.hash)
5681                }) {
5682                    self.finalized_head = None;
5683                }
5684                let mut enriched_ancestor = *common_ancestor;
5685                if let Some(entry) = self.journal.iter().find(|entry| {
5686                    entry.block.number == common_ancestor.number
5687                        && entry.block.hash == common_ancestor.hash
5688                }) {
5689                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5690                }
5691                if let Some(current) = self.coverage_head.as_ref()
5692                    && current.number == common_ancestor.number
5693                    && current.hash == common_ancestor.hash
5694                {
5695                    enrich_block_ref(&mut enriched_ancestor, current);
5696                }
5697                self.coverage_head = Some(enriched_ancestor);
5698                cache.advance_compact_block(
5699                    enriched_ancestor.number,
5700                    enriched_ancestor.hash,
5701                    enriched_ancestor.timestamp,
5702                    false,
5703                );
5704                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5705                self.coverage_head = Some(enriched_ancestor);
5706                self.trim_journal();
5707            }
5708        }
5709        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5710            control,
5711        })));
5712    }
5713
5714    fn validate_ingest_sequence(
5715        &self,
5716        pre_record_controls: &[ChainControl],
5717        post_record_controls: &[ChainControl],
5718        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5719    ) -> Result<ChainControlState, ReactiveError> {
5720        let mut controls =
5721            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
5722        controls.extend_from_slice(pre_record_controls);
5723        controls.extend_from_slice(post_record_controls);
5724        let state = CanonicalSequenceState::new(
5725            self.journal.iter().map(|entry| entry.block).collect(),
5726            self.coverage_head,
5727            self.safe_head,
5728            self.finalized_head,
5729        );
5730        let record_metadata = records
5731            .iter()
5732            .map(|(record, _, scope)| (record, *scope))
5733            .collect::<Vec<_>>();
5734        let validation = validate_canonical_sequence_parts(
5735            &state,
5736            &controls,
5737            &record_metadata,
5738            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
5739        )
5740        .map_err(CanonicalSequenceError::into_reactive_error)?;
5741        let mut resolved_canonical_blocks = HashMap::new();
5742        for mutation in validation.mutations() {
5743            if let CanonicalSequenceMutation::Canonical(block) = mutation {
5744                resolved_canonical_blocks
5745                    .entry((block.number, block.hash))
5746                    .and_modify(|known| enrich_block_ref(known, block))
5747                    .or_insert(*block);
5748            }
5749        }
5750        Ok(ChainControlState {
5751            journal_invalidated_from: pre_record_controls
5752                .iter()
5753                .filter_map(|control| match control {
5754                    ChainControl::Reorg {
5755                        common_ancestor, ..
5756                    } => Some(common_ancestor.number.saturating_add(1)),
5757                    _ => None,
5758                })
5759                .min(),
5760            resolved_canonical_blocks,
5761        })
5762    }
5763
5764    fn recover_for_canonical_input(
5765        &mut self,
5766        cache: &mut EvmCache,
5767        block: &BlockRef,
5768        gap_is_certified: bool,
5769        parentless_replacement_is_proven: bool,
5770        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5771    ) -> Option<ReorgReport<N>> {
5772        let latest = self
5773            .coverage_head
5774            .or_else(|| self.journal.back().map(|entry| entry.block))?;
5775
5776        if latest.number == block.number && latest.hash == block.hash {
5777            return None;
5778        }
5779
5780        if self
5781            .journal
5782            .iter()
5783            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
5784        {
5785            return None;
5786        }
5787
5788        if latest.number.checked_add(1) == Some(block.number)
5789            && (block.parent_hash == Some(latest.hash)
5790                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
5791        {
5792            return None;
5793        }
5794
5795        if latest
5796            .number
5797            .checked_add(1)
5798            .is_some_and(|next| block.number > next)
5799        {
5800            // A forward gap: blocks between the journaled head and the arriving
5801            // block were never observed (e.g. a disconnect). A historical
5802            // canonical-progress delivery can instead be covered by a
5803            // compatible post-record progress/barrier certificate proving the
5804            // sparse interval contained no matching events. Live canonical
5805            // gaps remain observable and escalate health.
5806            if !gap_is_certified {
5807                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
5808                health_reports.extend(self.escalate_trust(block.number));
5809                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
5810                    MissedRangeReport {
5811                        from: latest.number + 1,
5812                        to: block.number - 1,
5813                        block: block.number,
5814                        _network: PhantomData,
5815                    },
5816                )));
5817            }
5818            return None;
5819        }
5820
5821        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
5822            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
5823                entry.block.number.checked_add(1) == Some(block.number)
5824                    && entry.block.hash == parent_hash
5825            }) {
5826                let parent = self.journal[parent_index].block;
5827                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
5828                (self.drain_journal_after(parent_index), Some(parent))
5829            } else {
5830                // An unknown immediate parent proves exactly N-1 and nothing
5831                // earlier. Preserve a prefix only when the accepted path is an
5832                // immediate child of the runtime's exact finalized anchor;
5833                // otherwise every cached BLOCKHASH may belong to the displaced
5834                // branch and must be cleared fail-closed.
5835                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
5836                    finalized.number.checked_add(1) == Some(block.number)
5837                        && parent_hash == finalized.hash
5838                });
5839                let invalidated_from = proven_finalized_anchor
5840                    .map_or(0, |finalized| finalized.number.saturating_add(1));
5841                cache.invalidate_cached_block_hashes_from(invalidated_from);
5842                if block.number > 0 {
5843                    // Even when the parent falls outside the retained journal,
5844                    // the arriving child authenticates its exact hash. Restore
5845                    // that one known value after clearing the displaced branch.
5846                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
5847                }
5848                health_reports.extend(self.warn_under_recovery(block.number));
5849                let dropped = if let Some(finalized) = proven_finalized_anchor {
5850                    self.drain_journal_from_number(finalized.number.saturating_add(1))
5851                } else {
5852                    self.drain_journal_from_number(0)
5853                };
5854                (dropped, proven_finalized_anchor)
5855            }
5856        } else {
5857            // No parent identity authenticates any prefix of the arriving path.
5858            cache.invalidate_cached_block_hashes_from(0);
5859            health_reports.extend(self.warn_under_recovery(block.number));
5860            (self.drain_journal_from_number(0), None)
5861        };
5862
5863        self.rebase_validation_state_from(
5864            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
5865        );
5866        let report = self
5867            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
5868            .or_else(|| {
5869                Some(ReorgReport {
5870                    dropped: Some(latest),
5871                    dropped_blocks: Vec::new(),
5872                    dropped_inputs: Vec::new(),
5873                    rollback_updates: Vec::new(),
5874                    rollback_diff: StateDiff::default(),
5875                    purge_updates: Vec::new(),
5876                    purge_diff: StateDiff::default(),
5877                    canceled_resyncs: self
5878                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
5879                    reason: ReorgReason::ParentMismatch,
5880                    _network: PhantomData,
5881                })
5882            });
5883        self.coverage_head = authenticated_anchor;
5884        for head in [&mut self.safe_head, &mut self.finalized_head] {
5885            if head.is_some_and(|head| {
5886                authenticated_anchor.is_none_or(|anchor| {
5887                    head.number > anchor.number
5888                        || (head.number == anchor.number && head.hash != anchor.hash)
5889                })
5890            }) {
5891                *head = None;
5892            }
5893        }
5894        if let Some(anchor) = authenticated_anchor {
5895            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
5896        }
5897        report
5898    }
5899
5900    fn recover_for_reorged_input(
5901        &mut self,
5902        cache: &mut EvmCache,
5903        record: &ReactiveInputRecord<N>,
5904        batch_dropped: &mut BatchDroppedCanonical,
5905        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5906    ) -> Option<ReorgReport<N>> {
5907        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
5908        if batch_dropped.contains(&incoming_dropped_block) {
5909            // A previous signal in this atomic batch already drained this
5910            // block/span. Preserve the lifecycle input report, but do not
5911            // repeat rollback or classify the provider's per-log removals as a
5912            // deep reorg. Exact hash-pinned repairs still need cancellation.
5913            let canceled_resyncs = self
5914                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
5915            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
5916                dropped: Some(incoming_dropped_block),
5917                dropped_blocks: vec![incoming_dropped_block],
5918                dropped_inputs: Vec::new(),
5919                rollback_updates: Vec::new(),
5920                rollback_diff: StateDiff::default(),
5921                purge_updates: Vec::new(),
5922                purge_diff: StateDiff::default(),
5923                canceled_resyncs,
5924                reason,
5925                _network: PhantomData,
5926            });
5927        }
5928        let exact_index = self.journal.iter().position(|entry| {
5929            entry.block.number == incoming_dropped_block.number
5930                && entry.block.hash == incoming_dropped_block.hash
5931        });
5932        let mut dropped_block = exact_index
5933            .map(|index| self.journal[index].block)
5934            .or_else(|| {
5935                self.coverage_head.filter(|known| {
5936                    known.number == incoming_dropped_block.number
5937                        && known.hash == incoming_dropped_block.hash
5938                })
5939            })
5940            .unwrap_or(incoming_dropped_block);
5941        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
5942        let replacement_is_known = exact_index.is_none()
5943            && (self.journal.iter().any(|entry| {
5944                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
5945            }) || self.coverage_head.is_some_and(|head| {
5946                head.number == dropped_block.number && head.hash != dropped_block.hash
5947            }));
5948
5949        if replacement_is_known {
5950            // A delayed/duplicate removed log for the displaced hash is
5951            // idempotent. Draining by number here would destroy the already
5952            // installed replacement branch at the same height.
5953            let canceled_resyncs =
5954                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
5955            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
5956                dropped: Some(dropped_block),
5957                dropped_blocks: vec![dropped_block],
5958                dropped_inputs: Vec::new(),
5959                rollback_updates: Vec::new(),
5960                rollback_diff: StateDiff::default(),
5961                purge_updates: Vec::new(),
5962                purge_diff: StateDiff::default(),
5963                canceled_resyncs,
5964                reason,
5965                _network: PhantomData,
5966            });
5967        }
5968
5969        let authenticated_anchor = exact_index.and_then(|index| {
5970            let ancestor_number = dropped_block.number.checked_sub(1)?;
5971            let retained = self
5972                .journal
5973                .iter()
5974                .take(index)
5975                .rev()
5976                .find(|entry| entry.block.number == ancestor_number)
5977                .map(|entry| entry.block);
5978            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
5979                number: ancestor_number,
5980                hash,
5981                parent_hash: None,
5982                timestamp: None,
5983            });
5984            let finalized_fallback = self
5985                .finalized_head
5986                .filter(|head| head.number == ancestor_number);
5987            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
5988            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
5989                .into_iter()
5990                .flatten()
5991            {
5992                if head.number == anchor.number && head.hash == anchor.hash {
5993                    enrich_block_ref(&mut anchor, head);
5994                }
5995            }
5996            Some(anchor)
5997        });
5998
5999        cache.invalidate_cached_block_hashes_from(dropped_block.number);
6000        let dropped = if let Some(index) = exact_index {
6001            self.drain_journal_from(index)
6002        } else {
6003            health_reports.extend(self.warn_under_recovery(dropped_block.number));
6004            self.drain_journal_from_number(dropped_block.number)
6005        };
6006        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
6007        batch_dropped.record_drained(&drained_blocks);
6008        batch_dropped.record_identity(&dropped_block);
6009        self.rebase_validation_state_from(dropped_block.number);
6010
6011        let recovered_journal = !dropped.is_empty();
6012        let report = if !recovered_journal {
6013            let canceled_resyncs =
6014                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6015            Some(ReorgReport {
6016                dropped: Some(dropped_block),
6017                dropped_blocks: Vec::new(),
6018                dropped_inputs: Vec::new(),
6019                rollback_updates: Vec::new(),
6020                rollback_diff: StateDiff::default(),
6021                purge_updates: Vec::new(),
6022                purge_diff: StateDiff::default(),
6023                canceled_resyncs,
6024                reason,
6025                _network: PhantomData,
6026            })
6027        } else {
6028            self.recover_dropped_journals(cache, dropped, reason)
6029        };
6030
6031        if recovered_journal {
6032            if let Some(anchor) = authenticated_anchor {
6033                self.coverage_head = Some(anchor);
6034            }
6035            let coverage = self.coverage_head;
6036            for head in [&mut self.safe_head, &mut self.finalized_head] {
6037                if head.is_some_and(|head| {
6038                    coverage.is_none_or(|coverage| {
6039                        head.number > coverage.number
6040                            || (head.number == coverage.number && head.hash != coverage.hash)
6041                    })
6042                }) {
6043                    *head = None;
6044                }
6045            }
6046        }
6047
6048        if recovered_journal
6049            && report.is_some()
6050            && let Some(head) = self.coverage_head
6051        {
6052            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
6053        }
6054        report
6055    }
6056
6057    /// Warn that a reorg references a block no longer resident in the journal, so
6058    /// recovery is limited to the blocks still journaled — effects from aged-out
6059    /// blocks are neither rolled back nor purged (the freshness/validation loop is
6060    /// the backstop). Makes the under-recovery observable instead of silent.
6061    ///
6062    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
6063    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
6064    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
6065    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
6066    /// transition is returned so the caller can thread it into the ingest cycle's
6067    /// dispatched reports.
6068    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
6069        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
6070        tracing::warn!(
6071            reorg_block = reorg_number,
6072            oldest_journaled = ?oldest_journaled,
6073            journal_depth = self.config.journal_depth,
6074            "reactive reorg recovery is incomplete: the reorged block is no longer \
6075             in the journal, so effects from blocks aged out of the journal are \
6076             neither rolled back nor purged (the freshness/validation loop is the \
6077             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
6078             reorgs precisely."
6079        );
6080
6081        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
6082
6083        self.escalate_trust(reorg_number)
6084    }
6085
6086    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
6087        advance_or_enrich_coverage(&mut self.coverage_head, block);
6088        let entry = self.journal_entry_mut(block);
6089        let enriched = entry.block;
6090        if !entry.inputs.contains(&input_ref) {
6091            entry.inputs.push(input_ref);
6092        }
6093        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
6094        self.trim_journal();
6095    }
6096
6097    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6098        let entry = self.journal_entry_mut(block);
6099        if !entry.handler_ids.contains(&applied.handler_id) {
6100            entry.handler_ids.push(applied.handler_id.clone());
6101        }
6102        entry.rollback_diffs.push(applied.diff.clone());
6103        entry.applied.push(applied);
6104        self.trim_journal();
6105    }
6106
6107    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6108        let Some(entry) = self
6109            .journal
6110            .iter_mut()
6111            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
6112        else {
6113            return;
6114        };
6115        if !entry.handler_ids.contains(&applied.handler_id) {
6116            entry.handler_ids.push(applied.handler_id.clone());
6117        }
6118        entry.rollback_diffs.push(applied.diff.clone());
6119        entry.applied.push(applied);
6120    }
6121
6122    fn record_journal_resync(&mut self, report: &ResyncReport) {
6123        if report.diff.is_empty() {
6124            return;
6125        }
6126        let Some(block) = single_hash_pinned_resync_block(report) else {
6127            return;
6128        };
6129        let entry = self.journal_entry_mut(&block);
6130        entry.rollback_diffs.push(report.diff.clone());
6131        entry.resynced.push(report.clone());
6132        self.trim_journal();
6133    }
6134
6135    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
6136        if let Some(index) = self
6137            .journal
6138            .iter()
6139            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6140        {
6141            enrich_block_ref(&mut self.journal[index].block, block);
6142            return &mut self.journal[index];
6143        }
6144
6145        self.journal.push_back(BlockJournal {
6146            block: *block,
6147            inputs: Vec::new(),
6148            applied: Vec::new(),
6149            handler_ids: Vec::new(),
6150            resynced: Vec::new(),
6151            rollback_diffs: Vec::new(),
6152        });
6153        let index = self.journal.len() - 1;
6154        &mut self.journal[index]
6155    }
6156
6157    fn trim_journal(&mut self) {
6158        if self.config.journal_depth == 0 {
6159            self.journal.clear();
6160            return;
6161        }
6162        while self.journal.len() > self.config.journal_depth {
6163            self.journal.pop_front();
6164        }
6165    }
6166
6167    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6168        self.journal.drain((index + 1)..).collect()
6169    }
6170
6171    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6172        self.journal.drain(index..).collect()
6173    }
6174
6175    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
6176        let Some(index) = self
6177            .journal
6178            .iter()
6179            .position(|entry| entry.block.number >= number)
6180        else {
6181            return Vec::new();
6182        };
6183        self.drain_journal_from(index)
6184    }
6185
6186    fn recover_dropped_journals(
6187        &mut self,
6188        cache: &mut EvmCache,
6189        dropped: Vec<BlockJournal<N>>,
6190        reason: ReorgReason,
6191    ) -> Option<ReorgReport<N>> {
6192        if dropped.is_empty() {
6193            return None;
6194        }
6195
6196        let first_dropped_block = dropped
6197            .iter()
6198            .map(|entry| entry.block.number)
6199            .min()
6200            .expect("non-empty dropped journal set");
6201        self.rebase_validation_state_from(first_dropped_block);
6202        if self
6203            .safe_head
6204            .is_some_and(|head| head.number >= first_dropped_block)
6205        {
6206            self.safe_head = None;
6207        }
6208
6209        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
6210        let dropped_inputs: Vec<_> = dropped
6211            .iter()
6212            .flat_map(|entry| entry.inputs.iter().copied())
6213            .collect();
6214        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
6215        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
6216        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
6217        let purge_updates: Vec<_> = purge_scopes
6218            .iter()
6219            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
6220            .collect();
6221
6222        let rollback_diff = if rollback_updates.is_empty() {
6223            StateDiff::default()
6224        } else {
6225            cache.apply_updates(&rollback_updates)
6226        };
6227        let purge_diff = if purge_updates.is_empty() {
6228            StateDiff::default()
6229        } else {
6230            cache.apply_updates(&purge_updates)
6231        };
6232        self.coverage_head = self.journal.back().map(|entry| entry.block);
6233
6234        Some(ReorgReport {
6235            dropped: dropped_blocks.first().cloned(),
6236            dropped_blocks,
6237            dropped_inputs,
6238            rollback_updates,
6239            rollback_diff,
6240            purge_updates,
6241            purge_diff,
6242            canceled_resyncs,
6243            reason,
6244            _network: PhantomData,
6245        })
6246    }
6247
6248    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
6249        if let Some(freshness) = self.freshness.as_mut() {
6250            freshness.invalidate_valid_through_from(first_dropped_block);
6251        }
6252        self.tracked_roots
6253            .retain(|_, baseline| baseline.last_block < first_dropped_block);
6254        if self
6255            .last_gate_block
6256            .is_some_and(|block| block >= first_dropped_block)
6257        {
6258            self.last_gate_block = self
6259                .tracked_roots
6260                .values()
6261                .map(|baseline| baseline.last_block)
6262                .max();
6263        }
6264        // Touch provenance is window-relative. Once any block in that window
6265        // is dropped, retaining the union could incorrectly mark a replacement
6266        // branch root move as decoder-covered.
6267        self.touched_since_gate.clear();
6268    }
6269
6270    fn cancel_resyncs_for_dropped_blocks(
6271        &mut self,
6272        dropped_blocks: &[BlockRef],
6273    ) -> Vec<ResyncRequest> {
6274        let mut canceled = Vec::new();
6275        self.pending_resyncs.retain(|request| {
6276            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6277            if should_cancel {
6278                canceled.push(request.clone());
6279            }
6280            !should_cancel
6281        });
6282        canceled
6283    }
6284
6285    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6286        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6287        self.pending_resyncs
6288            .retain(|request| !ids.contains(&request.id));
6289    }
6290}
6291
6292fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
6293    cache.set_block(BlockId::pending());
6294    cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
6295    cache.set_coinbase(flashblock.beneficiary);
6296    cache.set_prevrandao(flashblock.prevrandao);
6297    cache.set_block_gas_limit(flashblock.gas_limit);
6298    cache.set_timestamp(flashblock.timestamp);
6299}
6300
6301/// Validate one provider-neutral delivery envelope without mutating runtime or
6302/// cache state.
6303///
6304/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6305/// composite/remote subscribers. It validates explicit reorg controls before
6306/// records, canonical record identity and implicit-reorg finality, then
6307/// progress/barrier/safe/finalized controls. All identity assertions in the
6308/// envelope must agree at each height. Retained history may be sparse; an
6309/// explicit common ancestor need not itself be retained when the oldest
6310/// retained entry is at or below it. Ancestors and removed blocks outside that
6311/// rollback horizon are rejected, so a durable caller cannot persist a partial
6312/// rollback. The runtime uses this same implementation with an internal
6313/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6314///
6315/// The returned state and mutations are cache-free. Callers that durably stage
6316/// delivery should publish/persist them only at their own acknowledgement
6317/// boundary.
6318///
6319/// This validator is deliberately chain-agnostic and does not compare
6320/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6321/// no chain id. Cross-service/composite callers must bind one authoritative
6322/// chain identity outside this state before sharing or advancing it; runtime
6323/// ingestion separately checks the batch id against [`EvmCache`].
6324///
6325/// # Errors
6326///
6327/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6328/// metadata is malformed or conflicting, and
6329/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6330/// invalid canonical transition, incomplete rollback proof, contradictory
6331/// identity, or invalid coverage/finality relationship.
6332pub fn validate_canonical_sequence<N: Network>(
6333    state: &CanonicalSequenceState,
6334    batch: &ReactiveInputBatch<N>,
6335) -> Result<CanonicalSequenceValidation, ReactiveError> {
6336    validate_canonical_sequence_diagnostic(state, batch)
6337        .map_err(CanonicalSequenceError::into_reactive_error)
6338}
6339
6340/// Validate one provider-neutral delivery envelope and retain structured
6341/// rollback diagnostics.
6342///
6343/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6344/// it at durable/composite source boundaries that need to distinguish malformed
6345/// input from an otherwise valid transition whose rollback ancestor has aged
6346/// out of the retained history. Callers should branch on
6347/// [`CanonicalSequenceError`] rather than parsing error text.
6348///
6349/// # Errors
6350///
6351/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6352/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6353/// retained canonical history is required to prove the transition.
6354pub fn validate_canonical_sequence_diagnostic<N: Network>(
6355    state: &CanonicalSequenceState,
6356    batch: &ReactiveInputBatch<N>,
6357) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6358    validate_canonical_sequence_internal(
6359        state,
6360        batch,
6361        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6362    )
6363}
6364
6365/// Validate a composite-source envelope and normalize harmless coverage
6366/// overlap.
6367///
6368/// This has the same fail-closed rollback/finality/identity contract as
6369/// [`validate_canonical_sequence`]. In addition, an equal or older
6370/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6371/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6372/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6373/// same opaque id and `block: None`, preserving the synchronization event
6374/// without forwarding regressive coverage. An equal-height control that fills
6375/// absent parent/timestamp metadata is retained and applied. Older compatible
6376/// metadata enrichment is deliberately dropped together with its non-forwarded
6377/// control so the returned state remains identical to what the runtime will
6378/// observe. Unknown or conflicting stale identities remain errors.
6379///
6380/// # Errors
6381///
6382/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6383/// record identity/payload metadata, and
6384/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6385/// proven redundant or when rollback, adjacency, identity, coverage, or
6386/// finality validation fails.
6387pub fn normalize_and_validate_canonical_sequence<N: Network>(
6388    state: &CanonicalSequenceState,
6389    batch: &ReactiveInputBatch<N>,
6390) -> Result<CanonicalSequenceValidation, ReactiveError> {
6391    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6392        .map_err(CanonicalSequenceError::into_reactive_error)
6393}
6394
6395/// Validate and normalize one composite-source envelope while retaining
6396/// structured rollback diagnostics.
6397///
6398/// This is the diagnostic counterpart to
6399/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6400/// and normalization semantics, but reports history exhaustion as
6401/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6402/// prose [`ReactiveError::InvalidChainControl`].
6403///
6404/// # Errors
6405///
6406/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6407/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6408/// when the retained history cannot prove a complete rollback.
6409pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6410    state: &CanonicalSequenceState,
6411    batch: &ReactiveInputBatch<N>,
6412) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6413    validate_canonical_sequence_internal(
6414        state,
6415        batch,
6416        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6417    )
6418}
6419
6420fn validate_canonical_sequence_internal<N: Network>(
6421    state: &CanonicalSequenceState,
6422    batch: &ReactiveInputBatch<N>,
6423    policy: CanonicalSequenceValidationPolicy,
6424) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6425    let records = batch
6426        .records()
6427        .iter()
6428        .enumerate()
6429        .map(|(index, record)| {
6430            (
6431                record.clone(),
6432                DeliveryAudience::All,
6433                batch
6434                    .record_delivery_scope(index)
6435                    .expect("enumerated record always has a delivery scope"),
6436            )
6437        })
6438        .collect::<Vec<_>>();
6439    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6440    let records = records
6441        .iter()
6442        .map(|(record, _, scope)| (record, *scope))
6443        .collect::<Vec<_>>();
6444    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6445}
6446
6447#[derive(Clone, Copy)]
6448enum CanonicalSequenceValidationPolicy {
6449    RequireCompleteRollback,
6450    RequireCompleteRollbackNormalizeCoverage,
6451    ObserveIncompleteRollback,
6452}
6453
6454/// Stable category for a canonical transition that needs older retained
6455/// history before it can be durably accepted.
6456#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6457#[non_exhaustive]
6458pub enum CanonicalRollbackKind {
6459    /// An explicit reorg control names an ancestor outside retained history.
6460    Explicit,
6461    /// A removed/reorged record names a block outside retained history.
6462    Removed,
6463    /// An implicit canonical replacement has no retained parent proof.
6464    ImplicitParent,
6465    /// A removed block is not followed by a provable replacement/anchor.
6466    MissingReplacement,
6467}
6468
6469/// Structured failure returned by canonical-sequence diagnostic validation.
6470///
6471/// This type is intentionally independent of diagnostic prose so remote and
6472/// composite subscribers can select recovery behavior without string matching.
6473#[derive(Debug, thiserror::Error)]
6474#[non_exhaustive]
6475pub enum CanonicalSequenceError {
6476    /// The snapshot or envelope is intrinsically malformed or contradictory.
6477    #[error(transparent)]
6478    Invalid(#[from] ReactiveError),
6479    /// The transition may be valid, but its rollback proof lies outside the
6480    /// supplied retained canonical history.
6481    #[error(
6482        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6483    )]
6484    IncompleteRollback {
6485        /// Last ancestor height required to prove the rollback.
6486        common_ancestor: u64,
6487        /// Oldest retained canonical height supplied by the caller.
6488        oldest_retained: Option<u64>,
6489        /// Stable reason the history window is insufficient.
6490        kind: CanonicalRollbackKind,
6491    },
6492}
6493
6494#[derive(Clone, Copy, Debug)]
6495struct RequiredReorgAnchor {
6496    number: u64,
6497    block: Option<BlockRef>,
6498    permits_missing_child_parent: bool,
6499    must_be_consumed: bool,
6500}
6501
6502#[derive(Debug)]
6503struct SequenceRewind {
6504    common_ancestor: Option<BlockRef>,
6505    dropped: Vec<BlockRef>,
6506}
6507
6508impl RequiredReorgAnchor {
6509    const fn hash(self) -> Option<B256> {
6510        match self.block {
6511            Some(block) => Some(block.hash),
6512            None => None,
6513        }
6514    }
6515}
6516
6517impl CanonicalSequenceError {
6518    /// Whether retrying with an older retained history window may prove this
6519    /// same transition.
6520    pub const fn requires_history(&self) -> bool {
6521        matches!(self, Self::IncompleteRollback { .. })
6522    }
6523
6524    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6525    pub fn into_reactive_error(self) -> ReactiveError {
6526        match self {
6527            Self::Invalid(error) => error,
6528            Self::IncompleteRollback {
6529                common_ancestor,
6530                oldest_retained,
6531                kind,
6532            } => ReactiveError::InvalidChainControl {
6533                message: format!(
6534                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6535                ),
6536            },
6537        }
6538    }
6539}
6540
6541impl CanonicalSequenceValidationPolicy {
6542    const fn requires_complete_rollback(self) -> bool {
6543        matches!(
6544            self,
6545            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6546        )
6547    }
6548
6549    const fn normalizes_coverage(self) -> bool {
6550        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6551    }
6552}
6553
6554fn validate_canonical_sequence_parts<N: Network>(
6555    initial: &CanonicalSequenceState,
6556    controls: &[ChainControl],
6557    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6558    policy: CanonicalSequenceValidationPolicy,
6559) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6560    validate_canonical_sequence_snapshot(initial)?;
6561    let control_split = validate_control_phase_order(controls)?;
6562    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6563    let mut state = initial.clone();
6564    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6565    let mut mutations = Vec::new();
6566    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6567    let mut batch_dropped = BatchDroppedCanonical::default();
6568    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6569    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6570    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6571    let rollback_oldest = initial
6572        .retained_canonical_history
6573        .first()
6574        .map(|block| block.number);
6575
6576    for control in pre_record_controls {
6577        normalized_chain_controls.push(control.clone());
6578        validate_sequence_control(&state, control)?;
6579        assert_chain_control_identities(&mut asserted_blocks, control)?;
6580        let ChainControl::Reorg {
6581            common_ancestor,
6582            old_tip,
6583            ..
6584        } = control
6585        else {
6586            unreachable!("phase validation leaves only reorg controls before records")
6587        };
6588        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6589            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6590        });
6591        let rollback_horizon_covers_ancestor = state
6592            .retained_canonical_history
6593            .first()
6594            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6595        if policy.requires_complete_rollback()
6596            && !exact_ancestor
6597            && !rollback_horizon_covers_ancestor
6598        {
6599            return Err(CanonicalSequenceError::IncompleteRollback {
6600                common_ancestor: common_ancestor.number,
6601                oldest_retained: rollback_oldest,
6602                kind: CanonicalRollbackKind::Explicit,
6603            });
6604        }
6605        let dropped = state
6606            .retained_canonical_history
6607            .iter()
6608            .copied()
6609            .filter(|block| block.number > common_ancestor.number)
6610            .collect::<Vec<_>>();
6611        state
6612            .retained_canonical_history
6613            .retain(|block| block.number <= common_ancestor.number);
6614        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6615        let mut enriched_ancestor = *common_ancestor;
6616        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6617            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6618        }) {
6619            enrich_block_ref(&mut enriched_ancestor, retained);
6620        }
6621        if let Some(coverage) = state.coverage_head.as_ref()
6622            && coverage.number == common_ancestor.number
6623            && coverage.hash == common_ancestor.hash
6624        {
6625            enrich_block_ref(&mut enriched_ancestor, coverage);
6626        }
6627        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6628        state.coverage_head = Some(enriched_ancestor);
6629        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6630        batch_dropped.record_explicit(common_ancestor, old_tip);
6631        batch_dropped.record_drained(&dropped);
6632        mutations.push(CanonicalSequenceMutation::Rewind {
6633            common_ancestor: Some(enriched_ancestor),
6634            dropped,
6635        });
6636    }
6637    let pre_record_state = state.clone();
6638    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6639
6640    for (record, scope) in records {
6641        if !scope.advances_canonical_state() {
6642            continue;
6643        }
6644        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6645            let incoming_dropped_block =
6646                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6647            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6648            validate_sequence_adjacent_parent_identity(
6649                &state,
6650                &incoming_dropped_block,
6651                "removed record",
6652            )?;
6653            let mut dropped_block = state
6654                .retained_canonical_history
6655                .iter()
6656                .find(|known| {
6657                    known.number == incoming_dropped_block.number
6658                        && known.hash == incoming_dropped_block.hash
6659                })
6660                .copied()
6661                .or_else(|| {
6662                    state.coverage_head.filter(|known| {
6663                        known.number == incoming_dropped_block.number
6664                            && known.hash == incoming_dropped_block.hash
6665                    })
6666                })
6667                .unwrap_or(incoming_dropped_block);
6668            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6669            validate_sequence_implicit_finality(&state, record, None)?;
6670            if dropped_block.number == 0 {
6671                return Err(ReactiveError::InvalidChainControl {
6672                    message: "a removed/reorged genesis block has no canonical parent anchor"
6673                        .into(),
6674                }
6675                .into());
6676            }
6677            let removed_identity = (dropped_block.number, dropped_block.hash);
6678            if let Some(previous_number) =
6679                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6680                && previous_number != dropped_block.number
6681            {
6682                return Err(ReactiveError::InvalidChainControl {
6683                    message: format!(
6684                        "removed hash {:?} is reused at heights {} and {}",
6685                        dropped_block.hash, previous_number, dropped_block.number
6686                    ),
6687                }
6688                .into());
6689            }
6690            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6691                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6692                    return Err(ReactiveError::InvalidChainControl {
6693                        message: format!(
6694                            "duplicate removed block {}:{:?} carries conflicting metadata",
6695                            dropped_block.number, dropped_block.hash
6696                        ),
6697                    }
6698                    .into());
6699                }
6700                enrich_block_ref(previous, &dropped_block);
6701            } else {
6702                removed_assertions.insert(removed_identity, dropped_block);
6703            }
6704            if asserted_blocks
6705                .get(&dropped_block.number)
6706                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6707            {
6708                return Err(ReactiveError::InvalidChainControl {
6709                    message: format!(
6710                        "removed block {}:{:?} is asserted canonical by the same envelope",
6711                        dropped_block.number, dropped_block.hash
6712                    ),
6713                }
6714                .into());
6715            }
6716            if batch_dropped.contains(&dropped_block) {
6717                continue;
6718            }
6719            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
6720                block.number == dropped_block.number && block.hash == dropped_block.hash
6721            }) {
6722                let dropped = state.retained_canonical_history.split_off(index);
6723                batch_dropped.record_drained(&dropped);
6724                let ancestor_number = dropped_block
6725                    .number
6726                    .checked_sub(1)
6727                    .expect("genesis removal was rejected above");
6728                let retained_anchor = state
6729                    .retained_canonical_history
6730                    .iter()
6731                    .rev()
6732                    .find(|head| head.number == ancestor_number)
6733                    .copied();
6734                let authenticated_anchor = retained_anchor
6735                    .or_else(|| {
6736                        dropped_block.parent_hash.map(|hash| BlockRef {
6737                            number: ancestor_number,
6738                            hash,
6739                            parent_hash: None,
6740                            timestamp: None,
6741                        })
6742                    })
6743                    .or_else(|| {
6744                        state
6745                            .finalized_head
6746                            .filter(|head| head.number == ancestor_number)
6747                    });
6748                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
6749                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
6750                        .into_iter()
6751                        .flatten()
6752                    {
6753                        if head.number == anchor.number && head.hash == anchor.hash {
6754                            enrich_block_ref(&mut anchor, head);
6755                        }
6756                    }
6757                    anchor
6758                });
6759                required_reorg_anchor = Some(RequiredReorgAnchor {
6760                    number: ancestor_number,
6761                    block: authenticated_anchor,
6762                    permits_missing_child_parent: retained_anchor.is_some(),
6763                    must_be_consumed: authenticated_anchor.is_none()
6764                        && state.retained_canonical_history.is_empty(),
6765                });
6766                state.coverage_head = authenticated_anchor
6767                    .or_else(|| state.retained_canonical_history.last().copied());
6768                if let Some(head) = state.coverage_head {
6769                    clear_sequence_heads_above(&mut state, &head);
6770                } else {
6771                    state.safe_head = None;
6772                    state.finalized_head = None;
6773                }
6774                mutations.push(CanonicalSequenceMutation::Rewind {
6775                    common_ancestor: state.coverage_head,
6776                    dropped,
6777                });
6778            } else {
6779                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
6780                    block.number == dropped_block.number && block.hash != dropped_block.hash
6781                }) || state.coverage_head.is_some_and(|head| {
6782                    head.number == dropped_block.number && head.hash != dropped_block.hash
6783                });
6784                if !replacement_is_known {
6785                    // Ordinary runtime ingestion deliberately keeps an unknown
6786                    // deep removal observable and lets the recovery path
6787                    // degrade health. With no exact retained rollback proof,
6788                    // this validator must not fabricate a new canonical head.
6789                    if policy.requires_complete_rollback() {
6790                        return Err(CanonicalSequenceError::IncompleteRollback {
6791                            common_ancestor: dropped_block
6792                                .number
6793                                .checked_sub(1)
6794                                .expect("genesis removal was rejected above"),
6795                            oldest_retained: rollback_oldest,
6796                            kind: CanonicalRollbackKind::Removed,
6797                        });
6798                    }
6799                    continue;
6800                }
6801            }
6802            continue;
6803        }
6804
6805        let Some(context_block) = canonical_record_block(record) else {
6806            continue;
6807        };
6808        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
6809        if post_record_controls
6810            .iter()
6811            .filter_map(canonical_coverage_control_block)
6812            .any(|asserted| {
6813                asserted.number == incoming_block.number
6814                    && asserted.hash == incoming_block.hash
6815                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
6816                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
6817                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
6818            })
6819        {
6820            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
6821        }
6822        let mut resolved_block = incoming_block;
6823        if let Some(asserted) = asserted_blocks
6824            .get(&incoming_block.number)
6825            .filter(|asserted| asserted.hash == incoming_block.hash)
6826        {
6827            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
6828                return Err(ReactiveError::InvalidChainControl {
6829                    message: format!(
6830                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6831                        incoming_block.number, incoming_block.hash
6832                    ),
6833                }
6834                .into());
6835            }
6836            enrich_block_ref(&mut resolved_block, asserted);
6837        }
6838        for asserted in post_record_controls
6839            .iter()
6840            .filter_map(chain_control_canonical_assertion)
6841            .filter(|asserted| {
6842                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
6843            })
6844        {
6845            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
6846                return Err(ReactiveError::InvalidChainControl {
6847                    message: format!(
6848                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6849                        incoming_block.number, incoming_block.hash
6850                    ),
6851                }
6852                .into());
6853            }
6854            enrich_block_ref(&mut resolved_block, asserted);
6855        }
6856        let replacement_anchor =
6857            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
6858        if resolved_block.parent_hash.is_none()
6859            && replacement_anchor.is_some_and(|anchor| {
6860                anchor.permits_missing_child_parent
6861                    && anchor.number.checked_add(1) == Some(resolved_block.number)
6862            })
6863        {
6864            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
6865        }
6866        let block = &resolved_block;
6867        if removed_assertions.contains_key(&(block.number, block.hash)) {
6868            return Err(ReactiveError::InvalidChainControl {
6869                message: format!(
6870                    "canonical block {}:{:?} is also removed by the same envelope",
6871                    block.number, block.hash
6872                ),
6873            }
6874            .into());
6875        }
6876        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
6877            && *removed_number != block.number
6878        {
6879            return Err(ReactiveError::InvalidChainControl {
6880                message: format!(
6881                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
6882                    block.hash, block.number, removed_number
6883                ),
6884            }
6885            .into());
6886        }
6887        let replacement_proven_by_removal =
6888            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
6889        if replacement_anchor.is_some() {
6890            required_reorg_anchor = None;
6891        }
6892        validate_sequence_matching_metadata(&state, block, "canonical record")?;
6893        validate_sequence_implicit_finality(&state, record, Some(block))?;
6894        let implicit_replacement_requires_history = if replacement_proven_by_removal {
6895            false
6896        } else {
6897            sequence_implicit_replacement_requires_history(&state, block, policy)?
6898        };
6899        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
6900            return Err(CanonicalSequenceError::IncompleteRollback {
6901                common_ancestor: block.number.saturating_sub(1),
6902                oldest_retained: rollback_oldest,
6903                kind: CanonicalRollbackKind::ImplicitParent,
6904            });
6905        }
6906        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
6907        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
6908            anchor.permits_missing_child_parent
6909                && anchor.number.checked_add(1) == Some(block.number)
6910        });
6911        if let Some(rewind) =
6912            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
6913        {
6914            mutations.push(CanonicalSequenceMutation::Rewind {
6915                common_ancestor: rewind.common_ancestor,
6916                dropped: rewind.dropped,
6917            });
6918        }
6919        mutations.push(CanonicalSequenceMutation::Canonical(*block));
6920    }
6921
6922    for control in post_record_controls {
6923        if let Some(block) = chain_control_canonical_assertion(control)
6924            && removed_assertions.contains_key(&(block.number, block.hash))
6925        {
6926            return Err(ReactiveError::InvalidChainControl {
6927                message: format!(
6928                    "canonical block {}:{:?} is also removed by the same envelope",
6929                    block.number, block.hash
6930                ),
6931            }
6932            .into());
6933        }
6934        if let Some(block) = chain_control_canonical_assertion(control)
6935            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
6936            && *removed_number != block.number
6937        {
6938            return Err(ReactiveError::InvalidChainControl {
6939                message: format!(
6940                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
6941                    block.hash, block.number, removed_number
6942                ),
6943            }
6944            .into());
6945        }
6946        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
6947            required_reorg_anchor.filter(|required| block.number > required.number)
6948        });
6949        if let Some(block) = canonical_coverage_control_block(control) {
6950            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
6951            if replacement_anchor.is_some() {
6952                required_reorg_anchor = None;
6953            }
6954        }
6955        assert_chain_control_identities(&mut asserted_blocks, control)?;
6956        let preserves_record_proof =
6957            canonical_coverage_control_block(control).is_some_and(|block| {
6958                record_proof_control_identities.contains(&(block.number, block.hash))
6959            });
6960        if policy.normalizes_coverage()
6961            && !preserves_record_proof
6962            && let Some(block) = canonical_coverage_control_block(control)
6963            && state
6964                .coverage_head
6965                .is_some_and(|head| block.number <= head.number)
6966        {
6967            let is_equal_coverage = state
6968                .coverage_head
6969                .is_some_and(|head| block.number == head.number);
6970            let known = state
6971                .coverage_head
6972                .as_ref()
6973                .filter(|head| head.number == block.number && head.hash == block.hash)
6974                .or_else(|| {
6975                    state
6976                        .retained_canonical_history
6977                        .iter()
6978                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
6979                });
6980            if let Some(known) = known
6981                && optional_block_refs_are_compatible(Some(known), Some(block))
6982                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
6983            {
6984                if let ChainControl::Barrier { id, .. } = control {
6985                    normalized_chain_controls.push(ChainControl::Barrier {
6986                        id: id.clone(),
6987                        block: None,
6988                    });
6989                }
6990                continue;
6991            }
6992        }
6993        validate_sequence_control(&state, control)?;
6994        normalized_chain_controls.push(control.clone());
6995        match control {
6996            ChainControl::Safe(block) => {
6997                set_or_enrich_block_ref(&mut state.safe_head, block);
6998                mutations.push(CanonicalSequenceMutation::Safe(
6999                    state.safe_head.expect("safe head was just installed"),
7000                ));
7001            }
7002            ChainControl::Finalized(block) => {
7003                set_or_enrich_block_ref(&mut state.finalized_head, block);
7004                mutations.push(CanonicalSequenceMutation::Finalized(
7005                    state
7006                        .finalized_head
7007                        .expect("finalized head was just installed"),
7008                ));
7009            }
7010            ChainControl::CanonicalProgress(block)
7011            | ChainControl::Barrier {
7012                block: Some(block), ..
7013            } => {
7014                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7015                    anchor.permits_missing_child_parent
7016                        && anchor.number.checked_add(1) == Some(block.number)
7017                }) || (replacement_anchor.is_none()
7018                    && block.parent_hash.is_none()
7019                    && state
7020                        .coverage_head
7021                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
7022                if let Some(rewind) =
7023                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7024                {
7025                    mutations.push(CanonicalSequenceMutation::Rewind {
7026                        common_ancestor: rewind.common_ancestor,
7027                        dropped: rewind.dropped,
7028                    });
7029                }
7030                mutations.push(CanonicalSequenceMutation::Canonical(*block));
7031            }
7032            ChainControl::Barrier { block: None, .. } => {}
7033            ChainControl::Reorg { .. } => {
7034                unreachable!("phase validation excludes post-record reorg controls")
7035            }
7036        }
7037    }
7038
7039    if let Some(required) = required_reorg_anchor
7040        && required.must_be_consumed
7041        && policy.requires_complete_rollback()
7042    {
7043        return Err(CanonicalSequenceError::IncompleteRollback {
7044            common_ancestor: required.number,
7045            oldest_retained: rollback_oldest,
7046            kind: CanonicalRollbackKind::MissingReplacement,
7047        });
7048    }
7049
7050    validate_canonical_sequence_snapshot(&state)?;
7051    Ok(CanonicalSequenceValidation {
7052        pre_record_state,
7053        next_state: state,
7054        mutations,
7055        normalized_chain_controls,
7056    })
7057}
7058
7059fn validate_canonical_sequence_snapshot(
7060    state: &CanonicalSequenceState,
7061) -> Result<(), ReactiveError> {
7062    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7063    let supplied_blocks = state
7064        .retained_canonical_history
7065        .iter()
7066        .chain(state.coverage_head.iter())
7067        .chain(state.safe_head.iter())
7068        .chain(state.finalized_head.iter())
7069        .collect::<Vec<_>>();
7070    validate_known_parent_hash_heights(&supplied_blocks)?;
7071    let mut prior = None::<BlockRef>;
7072    for block in &state.retained_canonical_history {
7073        if let Some(previous) = prior {
7074            if block.number < previous.number {
7075                return Err(invalid(
7076                    "retained canonical history is not ordered by block number".into(),
7077                ));
7078            }
7079            if block.number == previous.number {
7080                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
7081                {
7082                    "duplicate"
7083                } else {
7084                    "conflicting"
7085                };
7086                return Err(invalid(format!(
7087                    "retained canonical history contains {qualifier} identities at block {}",
7088                    block.number
7089                )));
7090            }
7091            if previous.number.checked_add(1) == Some(block.number)
7092                && block.parent_hash.is_some()
7093                && block.parent_hash != Some(previous.hash)
7094            {
7095                return Err(invalid(format!(
7096                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
7097                    block.number, block.hash, previous.number, previous.hash
7098                )));
7099            }
7100        }
7101        prior = Some(*block);
7102    }
7103    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
7104        return Err(invalid(
7105            "retained canonical history requires an authoritative coverage head".into(),
7106        ));
7107    }
7108    if let Some(head) = state.coverage_head.as_ref() {
7109        if let Some(retained) = state
7110            .retained_canonical_history
7111            .iter()
7112            .find(|entry| entry.number == head.number)
7113            && !optional_block_refs_are_compatible(Some(retained), Some(head))
7114        {
7115            return Err(invalid(format!(
7116                "coverage head {}:{:?} conflicts with retained identity {:?}",
7117                head.number, head.hash, retained
7118            )));
7119        }
7120        if state
7121            .retained_canonical_history
7122            .last()
7123            .is_some_and(|retained| retained.number > head.number)
7124        {
7125            return Err(invalid(
7126                "retained canonical history advances beyond the coverage head".into(),
7127            ));
7128        }
7129        if let Some(retained) = state.retained_canonical_history.last()
7130            && retained.number.checked_add(1) == Some(head.number)
7131            && head.parent_hash.is_some()
7132            && head.parent_hash != Some(retained.hash)
7133        {
7134            return Err(invalid(format!(
7135                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
7136                head.number, head.hash, retained.number, retained.hash
7137            )));
7138        }
7139    }
7140    if let Some(safe) = state.safe_head.as_ref() {
7141        validate_sequence_known_identity(state, safe, "safe")?;
7142        validate_sequence_head_within_coverage(state, safe, "safe")?;
7143        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
7144    }
7145    if let Some(finalized) = state.finalized_head.as_ref() {
7146        validate_sequence_known_identity(state, finalized, "finalized")?;
7147        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
7148        validate_coverage_descends_from_adjacent_head(
7149            state.coverage_head.as_ref(),
7150            finalized,
7151            "finalized",
7152        )?;
7153    }
7154    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
7155    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
7156        && (finalized.number > safe.number
7157            || (finalized.number == safe.number && finalized.hash != safe.hash))
7158    {
7159        return Err(invalid(
7160            "finalized head cannot advance beyond or conflict with safe head".into(),
7161        ));
7162    }
7163    Ok(())
7164}
7165
7166fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
7167    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
7168    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
7169    for block in blocks.iter().copied() {
7170        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
7171            && previous_height != block.number
7172        {
7173            return Err(ReactiveError::InvalidChainControl {
7174                message: format!(
7175                    "canonical hash {:?} is reused at heights {} and {}",
7176                    block.hash, previous_height, block.number
7177                ),
7178            });
7179        }
7180        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
7181            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
7182                return Err(ReactiveError::InvalidChainControl {
7183                    message: format!(
7184                        "canonical aliases at height {} carry conflicting identities or metadata",
7185                        block.number
7186                    ),
7187                });
7188            }
7189            enrich_block_ref(resolved, block);
7190        } else {
7191            resolved_by_height.insert(block.number, *block);
7192        }
7193    }
7194    for child in resolved_by_height.values() {
7195        let Some(parent_hash) = child.parent_hash else {
7196            continue;
7197        };
7198        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
7199            && parent_number.checked_add(1) != Some(child.number)
7200        {
7201            return Err(ReactiveError::InvalidChainControl {
7202                message: format!(
7203                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7204                    child.number, child.hash, parent_hash, parent_number
7205                ),
7206            });
7207        }
7208        if let Some(parent_number) = child.number.checked_sub(1)
7209            && let Some(parent) = resolved_by_height.get(&parent_number)
7210            && parent.hash != parent_hash
7211        {
7212            return Err(ReactiveError::InvalidChainControl {
7213                message: format!(
7214                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
7215                    child.number, child.hash, parent.number, parent.hash
7216                ),
7217            });
7218        }
7219    }
7220    Ok(())
7221}
7222
7223fn validate_coverage_descends_from_adjacent_head(
7224    coverage: Option<&BlockRef>,
7225    head: &BlockRef,
7226    label: &str,
7227) -> Result<(), ReactiveError> {
7228    let Some(coverage) = coverage else {
7229        return Ok(());
7230    };
7231    if head.number.checked_add(1) == Some(coverage.number)
7232        && coverage
7233            .parent_hash
7234            .is_some_and(|parent| parent != head.hash)
7235    {
7236        return Err(ReactiveError::InvalidChainControl {
7237            message: format!(
7238                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
7239                coverage.number, coverage.hash, head.number, head.hash
7240            ),
7241        });
7242    }
7243    Ok(())
7244}
7245
7246fn validate_sequence_control(
7247    state: &CanonicalSequenceState,
7248    control: &ChainControl,
7249) -> Result<(), ReactiveError> {
7250    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7251    match control {
7252        ChainControl::Safe(block) => {
7253            validate_sequence_known_identity(state, block, "safe")?;
7254            validate_sequence_head_within_coverage(state, block, "safe")?;
7255            if let Some(current) = state.safe_head.as_ref()
7256                && (block.number < current.number
7257                    || (block.number == current.number
7258                        && (block.hash != current.hash
7259                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7260            {
7261                return Err(invalid(format!(
7262                    "safe head {}:{:?} conflicts with current {}:{:?}",
7263                    block.number, block.hash, current.number, current.hash
7264                )));
7265            }
7266            if let Some(finalized) = state.finalized_head.as_ref()
7267                && (block.number < finalized.number
7268                    || (block.number == finalized.number && block.hash != finalized.hash))
7269            {
7270                return Err(invalid(
7271                    "safe head cannot precede or conflict with finalized head".into(),
7272                ));
7273            }
7274            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7275        }
7276        ChainControl::Finalized(block) => {
7277            validate_sequence_known_identity(state, block, "finalized")?;
7278            validate_sequence_head_within_coverage(state, block, "finalized")?;
7279            if let Some(current) = state.finalized_head.as_ref()
7280                && (block.number < current.number
7281                    || (block.number == current.number
7282                        && (block.hash != current.hash
7283                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7284            {
7285                return Err(invalid(format!(
7286                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7287                    block.number, block.hash, current.number, current.hash
7288                )));
7289            }
7290            if let Some(safe) = state.safe_head.as_ref()
7291                && (block.number > safe.number
7292                    || (block.number == safe.number && block.hash != safe.hash))
7293            {
7294                return Err(invalid(
7295                    "finalized head cannot advance beyond or conflict with safe head".into(),
7296                ));
7297            }
7298            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7299        }
7300        ChainControl::CanonicalProgress(block)
7301        | ChainControl::Barrier {
7302            block: Some(block), ..
7303        } => {
7304            validate_sequence_known_identity(state, block, "canonical coverage")?;
7305            if let Some(current) = state.coverage_head.as_ref()
7306                && (block.number < current.number
7307                    || (block.number == current.number && block.hash != current.hash))
7308            {
7309                return Err(invalid(format!(
7310                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7311                    block.number, block.hash, current.number, current.hash
7312                )));
7313            }
7314            if let Some(current) = state.coverage_head.as_ref()
7315                && current.number.checked_add(1) == Some(block.number)
7316                && block.parent_hash.is_some()
7317                && block.parent_hash != Some(current.hash)
7318            {
7319                return Err(invalid(format!(
7320                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7321                    block.number, block.hash, current.number, current.hash
7322                )));
7323            }
7324        }
7325        ChainControl::Barrier { block: None, .. } => {}
7326        ChainControl::Reorg {
7327            common_ancestor,
7328            old_tip,
7329            new_tip,
7330        } => {
7331            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7332            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7333            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7334            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7335            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7336            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7337            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7338            if let Some(current) = state.coverage_head.as_ref()
7339                && (old_tip.number != current.number
7340                    || old_tip.hash != current.hash
7341                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7342            {
7343                return Err(invalid(format!(
7344                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7345                    old_tip.number, old_tip.hash, current.number, current.hash
7346                )));
7347            }
7348            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7349                return Err(invalid(
7350                    "reorg common ancestor cannot be above either branch tip".into(),
7351                ));
7352            }
7353            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7354            {
7355                return Err(invalid(
7356                    "reorg must replace non-empty old and new branches above the common ancestor"
7357                        .into(),
7358                ));
7359            }
7360            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7361                return Err(invalid(
7362                    "reorg old and new tips cannot have the same canonical identity".into(),
7363                ));
7364            }
7365            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7366                if common_ancestor.number.checked_add(1) == Some(tip.number)
7367                    && tip.parent_hash != Some(common_ancestor.hash)
7368                {
7369                    return Err(invalid(format!(
7370                        "reorg {label} tip does not descend from the common ancestor"
7371                    )));
7372                }
7373            }
7374            if let Some(finalized) = state.finalized_head.as_ref()
7375                && (common_ancestor.number < finalized.number
7376                    || (common_ancestor.number == finalized.number
7377                        && common_ancestor.hash != finalized.hash))
7378            {
7379                return Err(invalid(
7380                    "reorg would cross or conflict with the finalized head".into(),
7381                ));
7382            }
7383        }
7384    }
7385    Ok(())
7386}
7387
7388fn validate_sequence_known_identity(
7389    state: &CanonicalSequenceState,
7390    block: &BlockRef,
7391    label: &str,
7392) -> Result<(), ReactiveError> {
7393    validate_sequence_known_hash_height(state, block, label)?;
7394    validate_sequence_known_parent_height(state, block, label)?;
7395    let known = state
7396        .coverage_head
7397        .as_ref()
7398        .filter(|head| head.number == block.number)
7399        .or_else(|| {
7400            state
7401                .retained_canonical_history
7402                .iter()
7403                .find(|entry| entry.number == block.number)
7404        });
7405    if let Some(known) = known
7406        && !optional_block_refs_are_compatible(Some(known), Some(block))
7407    {
7408        return Err(ReactiveError::InvalidChainControl {
7409            message: format!(
7410                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7411                block.number, block.hash, known
7412            ),
7413        });
7414    }
7415    Ok(())
7416}
7417
7418fn validate_sequence_known_parent_height(
7419    state: &CanonicalSequenceState,
7420    block: &BlockRef,
7421    label: &str,
7422) -> Result<(), ReactiveError> {
7423    let Some(parent_hash) = block.parent_hash else {
7424        return Ok(());
7425    };
7426    let known_parent = state
7427        .retained_canonical_history
7428        .iter()
7429        .chain(state.coverage_head.iter())
7430        .chain(state.safe_head.iter())
7431        .chain(state.finalized_head.iter())
7432        .find(|known| known.hash == parent_hash);
7433    if let Some(parent) = known_parent
7434        && parent.number.checked_add(1) != Some(block.number)
7435    {
7436        return Err(ReactiveError::InvalidChainControl {
7437            message: format!(
7438                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7439                block.number, block.hash, parent.hash, parent.number
7440            ),
7441        });
7442    }
7443    Ok(())
7444}
7445
7446fn validate_sequence_head_within_coverage(
7447    state: &CanonicalSequenceState,
7448    block: &BlockRef,
7449    label: &str,
7450) -> Result<(), ReactiveError> {
7451    let Some(coverage) = state.coverage_head.as_ref() else {
7452        return Err(ReactiveError::InvalidChainControl {
7453            message: format!("{label} head requires an authoritative coverage head"),
7454        });
7455    };
7456    if block.number > coverage.number
7457        || (block.number == coverage.number
7458            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7459    {
7460        return Err(ReactiveError::InvalidChainControl {
7461            message: format!(
7462                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7463                block.number, block.hash, coverage.number, coverage.hash
7464            ),
7465        });
7466    }
7467    Ok(())
7468}
7469
7470fn validate_sequence_matching_metadata(
7471    state: &CanonicalSequenceState,
7472    block: &BlockRef,
7473    label: &str,
7474) -> Result<(), ReactiveError> {
7475    validate_sequence_known_hash_height(state, block, label)?;
7476    validate_sequence_known_parent_height(state, block, label)?;
7477    let known = state
7478        .coverage_head
7479        .as_ref()
7480        .filter(|head| head.number == block.number && head.hash == block.hash)
7481        .or_else(|| {
7482            state
7483                .retained_canonical_history
7484                .iter()
7485                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7486        });
7487    if let Some(known) = known
7488        && !optional_block_refs_are_compatible(Some(known), Some(block))
7489    {
7490        return Err(ReactiveError::InvalidChainControl {
7491            message: format!(
7492                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7493                block.number, block.hash, known
7494            ),
7495        });
7496    }
7497    Ok(())
7498}
7499
7500fn validate_sequence_known_hash_height(
7501    state: &CanonicalSequenceState,
7502    block: &BlockRef,
7503    label: &str,
7504) -> Result<(), ReactiveError> {
7505    let known = state
7506        .retained_canonical_history
7507        .iter()
7508        .chain(state.coverage_head.iter())
7509        .chain(state.safe_head.iter())
7510        .chain(state.finalized_head.iter())
7511        .find(|known| known.hash == block.hash);
7512    if let Some(known) = known
7513        && known.number != block.number
7514    {
7515        return Err(ReactiveError::InvalidChainControl {
7516            message: format!(
7517                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7518                block.number, block.hash, known.number
7519            ),
7520        });
7521    }
7522    Ok(())
7523}
7524
7525fn validate_reorg_ancestor_against_retained_branch(
7526    state: &CanonicalSequenceState,
7527    ancestor: &BlockRef,
7528) -> Result<(), ReactiveError> {
7529    let adjacent_number = ancestor.number.checked_add(1);
7530    for retained in state
7531        .retained_canonical_history
7532        .iter()
7533        .chain(state.coverage_head.iter())
7534        .chain(state.safe_head.iter())
7535        .chain(state.finalized_head.iter())
7536    {
7537        if Some(retained.number) == adjacent_number
7538            && retained
7539                .parent_hash
7540                .is_some_and(|parent| parent != ancestor.hash)
7541        {
7542            return Err(ReactiveError::InvalidChainControl {
7543                message: format!(
7544                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7545                    ancestor.number,
7546                    ancestor.hash,
7547                    retained.number,
7548                    retained.hash,
7549                    retained.parent_hash
7550                ),
7551            });
7552        }
7553        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7554            return Err(ReactiveError::InvalidChainControl {
7555                message: format!(
7556                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7557                    ancestor.number, ancestor.hash, retained.number, retained.hash
7558                ),
7559            });
7560        }
7561    }
7562    Ok(())
7563}
7564
7565fn validate_sequence_adjacent_parent_identity(
7566    state: &CanonicalSequenceState,
7567    block: &BlockRef,
7568    label: &str,
7569) -> Result<(), ReactiveError> {
7570    let Some(parent_hash) = block.parent_hash else {
7571        return Ok(());
7572    };
7573    let Some(parent_number) = block.number.checked_sub(1) else {
7574        return Ok(());
7575    };
7576    let known_parent = state
7577        .retained_canonical_history
7578        .iter()
7579        .chain(state.coverage_head.iter())
7580        .chain(state.safe_head.iter())
7581        .chain(state.finalized_head.iter())
7582        .find(|known| known.number == parent_number);
7583    if let Some(known_parent) = known_parent
7584        && known_parent.hash != parent_hash
7585    {
7586        return Err(ReactiveError::InvalidChainControl {
7587            message: format!(
7588                "{label} block {}:{:?} 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    Ok(())
7594}
7595
7596fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7597    state
7598        .coverage_head
7599        .iter()
7600        .chain(state.retained_canonical_history.iter())
7601        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7602        .any(|known| {
7603            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7604                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7605        })
7606}
7607
7608fn validate_sequence_implicit_finality<N: Network>(
7609    state: &CanonicalSequenceState,
7610    record: &ReactiveInputRecord<N>,
7611    resolved_canonical_block: Option<&BlockRef>,
7612) -> Result<(), ReactiveError> {
7613    let Some(finalized) = state.finalized_head.as_ref() else {
7614        return Ok(());
7615    };
7616    if let Some((dropped, _)) = reorg_signal_block(record) {
7617        if dropped.number <= finalized.number {
7618            return Err(ReactiveError::InvalidChainControl {
7619                message: format!(
7620                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7621                    dropped.number, dropped.hash, finalized.number, finalized.hash
7622                ),
7623            });
7624        }
7625        return Ok(());
7626    }
7627    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7628        return Ok(());
7629    };
7630    let Some(latest) = state.coverage_head.as_ref() else {
7631        return Ok(());
7632    };
7633    if (block.number == latest.number && block.hash == latest.hash)
7634        || state
7635            .retained_canonical_history
7636            .iter()
7637            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7638        || (latest.number.checked_add(1) == Some(block.number)
7639            && block.parent_hash == Some(latest.hash))
7640        || latest
7641            .number
7642            .checked_add(1)
7643            .is_some_and(|next| block.number > next)
7644    {
7645        return Ok(());
7646    }
7647    let crosses_finalized = if block.number <= finalized.number {
7648        true
7649    } else if let Some(parent_hash) = block.parent_hash {
7650        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7651            false
7652        } else if let Some(parent_index) =
7653            state.retained_canonical_history.iter().rposition(|entry| {
7654                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7655            })
7656        {
7657            state
7658                .retained_canonical_history
7659                .iter()
7660                .skip(parent_index + 1)
7661                .any(|entry| entry.number <= finalized.number)
7662        } else {
7663            true
7664        }
7665    } else {
7666        true
7667    };
7668    if crosses_finalized {
7669        return Err(ReactiveError::InvalidChainControl {
7670            message: format!(
7671                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7672                block.number, block.hash, finalized.number, finalized.hash
7673            ),
7674        });
7675    }
7676    Ok(())
7677}
7678
7679fn validate_required_reorg_anchor(
7680    required: Option<RequiredReorgAnchor>,
7681    block: &BlockRef,
7682) -> Result<(), ReactiveError> {
7683    let Some(required) = required else {
7684        return Ok(());
7685    };
7686    let ancestor_hash = required.hash();
7687    let restores_ancestor =
7688        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
7689    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
7690        && ancestor_hash.is_some()
7691        && (block.parent_hash == ancestor_hash
7692            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
7693    if restores_ancestor || replaces_removed_child {
7694        return Ok(());
7695    }
7696    Err(ReactiveError::InvalidChainControl {
7697        message: format!(
7698            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
7699            block.number, block.hash, required.number
7700        ),
7701    })
7702}
7703
7704fn validate_replacement_reorg_anchor(
7705    required: Option<RequiredReorgAnchor>,
7706    block: &BlockRef,
7707    policy: CanonicalSequenceValidationPolicy,
7708    oldest_retained: Option<u64>,
7709) -> Result<bool, CanonicalSequenceError> {
7710    let Some(required) = required else {
7711        return Ok(false);
7712    };
7713    match validate_required_reorg_anchor(Some(required), block) {
7714        Ok(()) => Ok(true),
7715        Err(error) if required.block.is_some() => Err(error.into()),
7716        Err(_) if policy.requires_complete_rollback() => {
7717            Err(CanonicalSequenceError::IncompleteRollback {
7718                common_ancestor: required.number,
7719                oldest_retained,
7720                kind: CanonicalRollbackKind::MissingReplacement,
7721            })
7722        }
7723        Err(_) => Ok(false),
7724    }
7725}
7726
7727fn apply_sequence_canonical_block(
7728    state: &mut CanonicalSequenceState,
7729    block: &BlockRef,
7730    allow_parentless_adjacent_extension: bool,
7731) -> Result<Option<SequenceRewind>, ReactiveError> {
7732    let latest = state.coverage_head;
7733    let already_known = state
7734        .retained_canonical_history
7735        .iter()
7736        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7737    let repeats_tip =
7738        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
7739    let extends_tip = latest.is_some_and(|head| {
7740        head.number.checked_add(1) == Some(block.number)
7741            && (block.parent_hash == Some(head.hash)
7742                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
7743    });
7744    let forward_gap = latest.is_some_and(|head| {
7745        head.number
7746            .checked_add(1)
7747            .is_some_and(|next| block.number > next)
7748    });
7749    let mut rewind = None;
7750
7751    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
7752        let retained_parent = block.parent_hash.and_then(|parent_hash| {
7753            state
7754                .retained_canonical_history
7755                .iter()
7756                .rposition(|entry| {
7757                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7758                })
7759                .map(|index| (index, state.retained_canonical_history[index]))
7760        });
7761        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
7762            state.finalized_head.filter(|finalized| {
7763                finalized.number.checked_add(1) == Some(block.number)
7764                    && finalized.hash == parent_hash
7765            })
7766        });
7767        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
7768            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
7769            (Some(parent), dropped)
7770        } else if let Some(finalized) = finalized_parent {
7771            let dropped = state
7772                .retained_canonical_history
7773                .iter()
7774                .position(|entry| entry.number > finalized.number)
7775                .map_or_else(Vec::new, |index| {
7776                    state.retained_canonical_history.split_off(index)
7777                });
7778            (Some(finalized), dropped)
7779        } else {
7780            // The observable runtime policy may continue after an incomplete
7781            // rollback proof so it can degrade health and repair. The metadata
7782            // validator must nevertheless avoid claiming any old prefix is an
7783            // ancestor of the arriving branch: without the exact N-1 parent,
7784            // no retained identity is authenticated.
7785            (None, std::mem::take(&mut state.retained_canonical_history))
7786        };
7787        state.coverage_head = common_ancestor;
7788        if let Some(common_ancestor) = common_ancestor {
7789            clear_sequence_heads_above(state, &common_ancestor);
7790        } else {
7791            state.safe_head = None;
7792            state.finalized_head = None;
7793        }
7794        rewind = Some(SequenceRewind {
7795            common_ancestor,
7796            dropped,
7797        });
7798    }
7799    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
7800    advance_or_enrich_coverage(&mut state.coverage_head, block);
7801    Ok(rewind)
7802}
7803
7804fn sequence_implicit_replacement_requires_history(
7805    state: &CanonicalSequenceState,
7806    block: &BlockRef,
7807    policy: CanonicalSequenceValidationPolicy,
7808) -> Result<bool, ReactiveError> {
7809    let Some(latest) = state.coverage_head else {
7810        return Ok(false);
7811    };
7812    let already_known = state
7813        .retained_canonical_history
7814        .iter()
7815        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7816    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
7817    let extends_tip = latest.number.checked_add(1) == Some(block.number)
7818        && block.parent_hash == Some(latest.hash);
7819    let forward_gap = latest
7820        .number
7821        .checked_add(1)
7822        .is_some_and(|next| block.number > next);
7823    if already_known || repeats_tip || extends_tip || forward_gap {
7824        return Ok(false);
7825    }
7826    let Some(parent_hash) = block.parent_hash else {
7827        if policy.requires_complete_rollback() {
7828            return Err(ReactiveError::InvalidChainControl {
7829                message: format!(
7830                    "implicit canonical replacement {}:{:?} must identify its parent",
7831                    block.number, block.hash
7832                ),
7833            });
7834        }
7835        return Ok(true);
7836    };
7837    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
7838        state
7839            .retained_canonical_history
7840            .iter()
7841            .chain(state.coverage_head.iter())
7842            .chain(state.safe_head.iter())
7843            .chain(state.finalized_head.iter())
7844            .find(|known| known.number == parent_number)
7845    });
7846    if let Some(known_parent) = known_adjacent_parent
7847        && known_parent.hash != parent_hash
7848        && policy.requires_complete_rollback()
7849    {
7850        return Err(ReactiveError::InvalidChainControl {
7851            message: format!(
7852                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7853                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7854            ),
7855        });
7856    }
7857    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
7858        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7859    });
7860    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
7861        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
7862    });
7863    Ok(!retained_parent && !finalized_parent)
7864}
7865
7866fn upsert_sequence_history(
7867    history: &mut Vec<BlockRef>,
7868    block: &BlockRef,
7869) -> Result<(), ReactiveError> {
7870    if let Some(existing) = history
7871        .iter_mut()
7872        .find(|entry| entry.number == block.number)
7873    {
7874        if existing.hash != block.hash {
7875            return Err(ReactiveError::InvalidChainControl {
7876                message: format!(
7877                    "canonical block {}:{:?} conflicts with retained identity {:?}",
7878                    block.number, block.hash, existing
7879                ),
7880            });
7881        }
7882        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
7883            return Err(ReactiveError::InvalidChainControl {
7884                message: format!(
7885                    "canonical block {}:{:?} carries conflicting retained metadata",
7886                    block.number, block.hash
7887                ),
7888            });
7889        }
7890        enrich_block_ref(existing, block);
7891    } else {
7892        history.push(*block);
7893        history.sort_by_key(|entry| entry.number);
7894    }
7895    Ok(())
7896}
7897
7898fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
7899    if state.safe_head.as_ref().is_some_and(|head| {
7900        head.number > ancestor.number
7901            || (head.number == ancestor.number && head.hash != ancestor.hash)
7902    }) {
7903        state.safe_head = None;
7904    }
7905    if state.finalized_head.as_ref().is_some_and(|head| {
7906        head.number > ancestor.number
7907            || (head.number == ancestor.number && head.hash != ancestor.hash)
7908    }) {
7909        state.finalized_head = None;
7910    }
7911}
7912
7913fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
7914    let split = controls
7915        .iter()
7916        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
7917        .unwrap_or(controls.len());
7918    if controls[split..]
7919        .iter()
7920        .any(|control| matches!(control, ChainControl::Reorg { .. }))
7921    {
7922        return Err(ReactiveError::InvalidChainControl {
7923            message: "reorg controls must precede records and all post-record controls in a batch"
7924                .into(),
7925        });
7926    }
7927    Ok(split)
7928}
7929
7930fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
7931    match control {
7932        ChainControl::CanonicalProgress(block)
7933        | ChainControl::Barrier {
7934            block: Some(block), ..
7935        } => Some(block),
7936        ChainControl::Reorg { .. }
7937        | ChainControl::Safe(_)
7938        | ChainControl::Finalized(_)
7939        | ChainControl::Barrier { block: None, .. } => None,
7940    }
7941}
7942
7943fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
7944    match control {
7945        ChainControl::Safe(block)
7946        | ChainControl::Finalized(block)
7947        | ChainControl::CanonicalProgress(block)
7948        | ChainControl::Barrier {
7949            block: Some(block), ..
7950        } => Some(block),
7951        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
7952    }
7953}
7954
7955fn assert_chain_control_identities(
7956    asserted_blocks: &mut HashMap<u64, BlockRef>,
7957    control: &ChainControl,
7958) -> Result<(), ReactiveError> {
7959    match control {
7960        ChainControl::Safe(block)
7961        | ChainControl::Finalized(block)
7962        | ChainControl::CanonicalProgress(block)
7963        | ChainControl::Barrier {
7964            block: Some(block), ..
7965        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
7966        ChainControl::Barrier { block: None, .. } => Ok(()),
7967        ChainControl::Reorg {
7968            common_ancestor,
7969            new_tip,
7970            ..
7971        } => {
7972            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
7973            assert_canonical_block_identity(
7974                asserted_blocks,
7975                common_ancestor,
7976                "reorg common ancestor",
7977            )?;
7978            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
7979        }
7980    }
7981}
7982
7983fn assert_canonical_block_identity(
7984    asserted_blocks: &mut HashMap<u64, BlockRef>,
7985    block: &BlockRef,
7986    label: &str,
7987) -> Result<(), ReactiveError> {
7988    for asserted in asserted_blocks.values() {
7989        if asserted.hash == block.hash && asserted.number != block.number {
7990            return Err(ReactiveError::InvalidChainControl {
7991                message: format!(
7992                    "{label} hash {:?} is already asserted at height {}, not {}",
7993                    block.hash, asserted.number, block.number
7994                ),
7995            });
7996        }
7997        if block
7998            .parent_hash
7999            .is_some_and(|parent| parent == asserted.hash)
8000            && asserted.number.checked_add(1) != Some(block.number)
8001        {
8002            return Err(ReactiveError::InvalidChainControl {
8003                message: format!(
8004                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
8005                    block.number, block.hash, asserted.hash, asserted.number
8006                ),
8007            });
8008        }
8009        if asserted
8010            .parent_hash
8011            .is_some_and(|parent| parent == block.hash)
8012            && block.number.checked_add(1) != Some(asserted.number)
8013        {
8014            return Err(ReactiveError::InvalidChainControl {
8015                message: format!(
8016                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
8017                    asserted.number, asserted.hash, block.hash, block.number
8018                ),
8019            });
8020        }
8021    }
8022    if let Some(known) = asserted_blocks.get_mut(&block.number) {
8023        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
8024            return Err(ReactiveError::InvalidChainControl {
8025                message: format!(
8026                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
8027                    block.number, block.hash, known
8028                ),
8029            });
8030        }
8031        enrich_block_ref(known, block);
8032    } else {
8033        asserted_blocks.insert(block.number, *block);
8034    }
8035    Ok(())
8036}
8037
8038fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8039    match current {
8040        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8041            enrich_block_ref(current, incoming);
8042        }
8043        _ => *current = Some(*incoming),
8044    }
8045}
8046
8047fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8048    match current {
8049        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8050            enrich_block_ref(current, incoming);
8051        }
8052        Some(current) if current.number >= incoming.number => {}
8053        _ => *current = Some(*incoming),
8054    }
8055}
8056
8057fn validate_adjacent_finality(
8058    finalized: Option<&BlockRef>,
8059    safe: Option<&BlockRef>,
8060) -> Result<(), ReactiveError> {
8061    let Some((finalized, safe)) = finalized.zip(safe) else {
8062        return Ok(());
8063    };
8064    if finalized.number.checked_add(1) == Some(safe.number)
8065        && safe.parent_hash != Some(finalized.hash)
8066    {
8067        return Err(ReactiveError::InvalidChainControl {
8068            message: "adjacent safe head does not descend from finalized head".into(),
8069        });
8070    }
8071    Ok(())
8072}
8073
8074/// Fold every address a [`StateDiff`] references — genuine changes
8075/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
8076/// into `into`. Used by the per-block root gate to accumulate the batch's
8077/// decoder-touched address set: an account a decoder wrote (or tried to write) is
8078/// "covered," so a subsequent root move for it is not a coverage gap.
8079fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
8080    into.extend(diff.slots.iter().map(|change| change.address));
8081    into.extend(diff.accounts.iter().map(|change| change.address));
8082    into.extend(diff.purged.iter().map(|purge| purge.address));
8083    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
8084    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
8085    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
8086    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
8087}
8088
8089/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
8090/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
8091/// existing account-resync path (Wave 2). The id is derived from the address and
8092/// block so a repeated move on the same account/block coalesces deterministically.
8093fn root_moved_account_resync(
8094    address: Address,
8095    block: u64,
8096    fields: AccountFieldMask,
8097) -> ResyncRequest {
8098    ResyncRequest {
8099        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
8100        reason: ResyncReason::RootMoved,
8101        block: ResyncBlock::Number(block),
8102        targets: vec![ResyncTarget::Account { address, fields }],
8103        priority: ResyncPriority::Normal,
8104    }
8105}
8106
8107fn batch_preconfirmation<N: Network>(
8108    batch: &ReactiveInputBatch<N>,
8109) -> Result<Option<FlashblockRef>, ReactiveError> {
8110    let mut flashblock: Option<FlashblockRef> = None;
8111    let mut has_non_preconfirmed = false;
8112    for (index, record) in batch.records().iter().enumerate() {
8113        match &record.context.chain_status {
8114            ChainStatus::Preconfirmed {
8115                flashblock: current,
8116            } => {
8117                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
8118                    return Err(ReactiveError::InvalidInputRecord {
8119                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
8120                    });
8121                }
8122                if flashblock
8123                    .as_ref()
8124                    .is_some_and(|known| known != current.as_ref())
8125                {
8126                    return Err(ReactiveError::InvalidInputRecord {
8127                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
8128                    });
8129                }
8130                flashblock.get_or_insert_with(|| current.as_ref().clone());
8131            }
8132            _ => has_non_preconfirmed = true,
8133        }
8134    }
8135    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
8136        return Err(ReactiveError::InvalidInputRecord {
8137            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
8138        });
8139    }
8140    Ok(flashblock)
8141}
8142
8143fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
8144    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8145        return None;
8146    }
8147    if is_canonical_status(&record.context.chain_status) {
8148        return context_block_ref(&record.context);
8149    }
8150    None
8151}
8152
8153fn resolve_record_block_payload_metadata<N: Network>(
8154    record: &ReactiveInputRecord<N>,
8155    mut block: BlockRef,
8156) -> Result<BlockRef, ReactiveError> {
8157    let ReactiveInput::Log(log) = &record.input else {
8158        return Ok(block);
8159    };
8160    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
8161        return Err(ReactiveError::InvalidInputRecord {
8162            message: "log payload and canonical context carry different block identities".into(),
8163        });
8164    }
8165    if let Some(timestamp) = log.block_timestamp {
8166        if block.timestamp.is_some_and(|known| known != timestamp) {
8167            return Err(ReactiveError::InvalidInputRecord {
8168                message: "log payload and canonical context carry different block timestamps"
8169                    .into(),
8170            });
8171        }
8172        block.timestamp = Some(timestamp);
8173    }
8174    Ok(block)
8175}
8176
8177fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
8178    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
8179    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
8180        && record.context.block != Some(flashblock.block_ref())
8181    {
8182        return Err(invalid(
8183            "pre-confirmed status and context carry different partial block identities".into(),
8184        ));
8185    }
8186    let status_block = match &record.context.chain_status {
8187        ChainStatus::Included { block, .. }
8188        | ChainStatus::Safe { block }
8189        | ChainStatus::Finalized { block }
8190        | ChainStatus::Reorged {
8191            dropped_from: block,
8192        } => Some(block),
8193        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
8194        ChainStatus::Pending => None,
8195    };
8196    match (status_block, record.context.block.as_ref()) {
8197        (Some(status), Some(context)) if status == context => {}
8198        (Some(_), Some(_)) => {
8199            return Err(invalid(
8200                "chain status and context carry different block identities".into(),
8201            ));
8202        }
8203        (Some(_), None) => {
8204            return Err(invalid(
8205                "included or reorged input is missing its context block".into(),
8206            ));
8207        }
8208        (None, Some(_)) => {
8209            return Err(invalid(
8210                "pending input cannot carry a canonical context block".into(),
8211            ));
8212        }
8213        (None, None) => {}
8214    }
8215
8216    match &record.input {
8217        ReactiveInput::Log(log) => {
8218            let Some(block) = status_block else {
8219                return Err(invalid(
8220                    "log input must carry an included or reorged block identity".into(),
8221                ));
8222            };
8223            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
8224                return Err(invalid(
8225                    "removed log must carry reorged chain status".into(),
8226                ));
8227            }
8228            let block_number = log
8229                .block_number
8230                .ok_or_else(|| invalid("log is missing its block number".into()))?;
8231            let block_hash = log
8232                .block_hash
8233                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
8234            log.transaction_hash
8235                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
8236            let transaction_index = log
8237                .transaction_index
8238                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
8239            let log_index = log
8240                .log_index
8241                .ok_or_else(|| invalid("log is missing its log index".into()))?;
8242            if block_number != block.number
8243                || block_hash != block.hash
8244                || !optional_metadata_compatible(
8245                    log.block_timestamp.as_ref(),
8246                    block.timestamp.as_ref(),
8247                )
8248            {
8249                return Err(invalid(
8250                    "log payload and context carry different block identities".into(),
8251                ));
8252            }
8253            if record.context.transaction_index != Some(transaction_index)
8254                || record.context.log_index != Some(log_index)
8255            {
8256                return Err(invalid(
8257                    "log payload and context carry different transaction/log positions".into(),
8258                ));
8259            }
8260        }
8261        ReactiveInput::BlockHeader(header) => {
8262            if let Some(block) = status_block {
8263                if header.number() != block.number
8264                    || header.hash() != block.hash
8265                    || Some(header.parent_hash()) != block.parent_hash
8266                    || Some(header.timestamp()) != block.timestamp
8267                {
8268                    return Err(invalid(
8269                        "block header payload and context carry different block identities".into(),
8270                    ));
8271                }
8272            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8273                return Err(invalid("block header has an unsupported lifecycle".into()));
8274            }
8275            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8276                return Err(invalid(
8277                    "block header context cannot carry transaction/log positions".into(),
8278                ));
8279            }
8280        }
8281        ReactiveInput::FullBlock(block_response) => {
8282            let header = block_response.header();
8283            if let Some(block) = status_block {
8284                if header.number() != block.number
8285                    || header.hash() != block.hash
8286                    || Some(header.parent_hash()) != block.parent_hash
8287                    || Some(header.timestamp()) != block.timestamp
8288                {
8289                    return Err(invalid(
8290                        "full-block payload and context carry different block identities".into(),
8291                    ));
8292                }
8293            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8294                return Err(invalid("full block has an unsupported lifecycle".into()));
8295            }
8296            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8297                return Err(invalid(
8298                    "full-block context cannot carry transaction/log positions".into(),
8299                ));
8300            }
8301            if let Some(transactions) = block_response.transactions().as_transactions() {
8302                for (index, transaction) in transactions.iter().enumerate() {
8303                    if transaction
8304                        .block_hash()
8305                        .is_some_and(|hash| hash != header.hash())
8306                        || transaction
8307                            .block_number()
8308                            .is_some_and(|number| number != header.number())
8309                        || transaction
8310                            .transaction_index()
8311                            .is_some_and(|position| position != index as u64)
8312                    {
8313                        return Err(invalid(format!(
8314                            "full-block transaction {index} carries contradictory inclusion metadata"
8315                        )));
8316                    }
8317                    if transaction
8318                        .chain_id()
8319                        .zip(record.context.chain_id)
8320                        .is_some_and(|(transaction, context)| transaction != context)
8321                    {
8322                        return Err(invalid(format!(
8323                            "full-block transaction {index} carries a chain id conflicting with its context"
8324                        )));
8325                    }
8326                }
8327            }
8328        }
8329        ReactiveInput::PendingTxHash(_) => {
8330            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8331                return Err(invalid(
8332                    "pending transaction input must carry pending chain status".into(),
8333                ));
8334            }
8335            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8336                return Err(invalid(
8337                    "pending transaction context cannot carry canonical positions".into(),
8338                ));
8339            }
8340        }
8341        ReactiveInput::PendingTx(transaction) => {
8342            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8343                return Err(invalid(
8344                    "pending transaction input must carry pending chain status".into(),
8345                ));
8346            }
8347            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8348                return Err(invalid(
8349                    "pending transaction context cannot carry canonical positions".into(),
8350                ));
8351            }
8352            if transaction.block_hash().is_some()
8353                || transaction.block_number().is_some()
8354                || transaction.transaction_index().is_some()
8355            {
8356                return Err(invalid(
8357                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8358                ));
8359            }
8360            if transaction
8361                .chain_id()
8362                .zip(record.context.chain_id)
8363                .is_some_and(|(transaction, context)| transaction != context)
8364            {
8365                return Err(invalid(
8366                    "pending transaction carries a chain id conflicting with its context".into(),
8367                ));
8368            }
8369        }
8370    }
8371    Ok(())
8372}
8373
8374/// Best-effort per-block env refresh (Phase-8 step 2).
8375///
8376/// For a canonical record carrying a full header — a
8377/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8378/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8379/// `Some(result)` when a header was present (so the caller can surface a strict
8380/// validation error), and `None` for pending/reorged records or non-header
8381/// inputs, which must never drive a canonical env refresh.
8382fn advance_block_for_canonical_record<N: Network>(
8383    cache: &mut EvmCache,
8384    record: &ReactiveInputRecord<N>,
8385) -> Option<Result<(), BlockContextError>> {
8386    if !is_canonical_status(&record.context.chain_status) {
8387        return None;
8388    }
8389    match &record.input {
8390        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8391        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8392        _ => None,
8393    }
8394}
8395
8396fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8397    match &ctx.chain_status {
8398        ChainStatus::Included { block, .. }
8399        | ChainStatus::Safe { block }
8400        | ChainStatus::Finalized { block } => Some(block),
8401        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8402        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8403        ChainStatus::Pending => ctx.block.as_ref(),
8404    }
8405}
8406
8407fn reorg_signal_block<N: Network>(
8408    record: &ReactiveInputRecord<N>,
8409) -> Option<(BlockRef, ReorgReason)> {
8410    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8411        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8412    }
8413
8414    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8415        return Some((*dropped_from, ReorgReason::ReorgedInput));
8416    }
8417
8418    None
8419}
8420
8421fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8422    context_block_ref(&record.context)
8423        .cloned()
8424        .or_else(|| match &record.input {
8425            ReactiveInput::Log(log) => Some(BlockRef {
8426                number: log.block_number?,
8427                hash: log.block_hash?,
8428                parent_hash: None,
8429                timestamp: log.block_timestamp,
8430            }),
8431            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8432                number: header.number(),
8433                hash: header.hash(),
8434                parent_hash: Some(header.parent_hash()),
8435                timestamp: Some(header.timestamp()),
8436            }),
8437            ReactiveInput::FullBlock(block) => {
8438                let header = block.header();
8439                Some(BlockRef {
8440                    number: header.number(),
8441                    hash: header.hash(),
8442                    parent_hash: Some(header.parent_hash()),
8443                    timestamp: Some(header.timestamp()),
8444                })
8445            }
8446            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8447        })
8448}
8449
8450fn remove_canceled_resyncs_from_batch(
8451    resyncs: &mut Vec<ResyncRequest>,
8452    canceled: &[ResyncRequest],
8453) {
8454    if canceled.is_empty() {
8455        return;
8456    }
8457    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8458    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8459}
8460
8461fn resync_target_address(target: &ResyncTarget) -> Address {
8462    match target {
8463        ResyncTarget::StorageSlot { address, .. }
8464        | ResyncTarget::StorageSlots { address, .. }
8465        | ResyncTarget::Account { address, .. } => *address,
8466    }
8467}
8468
8469fn resync_request_targets_dropped_block(
8470    request: &ResyncRequest,
8471    dropped_blocks: &[BlockRef],
8472) -> bool {
8473    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8474        return false;
8475    };
8476    dropped_blocks
8477        .iter()
8478        .any(|block| block.hash == *hash && block.number == *number)
8479}
8480
8481fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8482    let first = report.requested.first()?.block.clone();
8483    if !report
8484        .requested
8485        .iter()
8486        .all(|request| request.block == first)
8487    {
8488        return None;
8489    }
8490
8491    let ResyncBlock::Hash { number, hash, .. } = first else {
8492        return None;
8493    };
8494
8495    Some(BlockRef {
8496        number,
8497        hash,
8498        parent_hash: None,
8499        timestamp: None,
8500    })
8501}
8502
8503fn purge_scopes_for_dropped_journals<N: Network>(
8504    dropped: &[BlockJournal<N>],
8505) -> Vec<(Address, PurgeScope)> {
8506    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8507    for entry in dropped.iter().rev() {
8508        for diff in entry.rollback_diffs.iter().rev() {
8509            merge_purge_scopes_for_diff(&mut scopes, diff);
8510        }
8511    }
8512    scopes
8513}
8514
8515fn rollback_updates_for_dropped_journals<N: Network>(
8516    dropped: &[BlockJournal<N>],
8517    purge_scopes: &[(Address, PurgeScope)],
8518) -> Vec<StateUpdate> {
8519    let purge_addresses: HashSet<_> = purge_scopes
8520        .iter()
8521        .map(|(address, _scope)| *address)
8522        .collect();
8523    let mut updates = Vec::new();
8524    for entry in dropped.iter().rev() {
8525        for diff in entry.rollback_diffs.iter().rev() {
8526            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8527        }
8528    }
8529    updates
8530}
8531
8532fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8533    for change in &diff.accounts {
8534        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8535    }
8536    for record in &diff.purged {
8537        merge_purge_scope(scopes, record.address, record.scope.clone());
8538    }
8539}
8540
8541fn push_rollback_updates_for_diff(
8542    updates: &mut Vec<StateUpdate>,
8543    diff: &StateDiff,
8544    purge_addresses: &HashSet<Address>,
8545) {
8546    for change in diff.slots.iter().rev() {
8547        if purge_addresses.contains(&change.address) {
8548            continue;
8549        }
8550        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8551    }
8552}
8553
8554fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8555    if let Some((_existing_address, existing_scope)) = scopes
8556        .iter_mut()
8557        .find(|(existing_address, _scope)| *existing_address == address)
8558    {
8559        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8560    } else {
8561        scopes.push((address, scope));
8562    }
8563}
8564
8565fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8566    match (left, right) {
8567        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8568        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8569        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8570            for slot in right {
8571                if !left.contains(&slot) {
8572                    left.push(slot);
8573                }
8574            }
8575            PurgeScope::Slots(left)
8576        }
8577    }
8578}
8579
8580#[derive(Clone, Debug)]
8581struct StorageFetchSlot {
8582    address: Address,
8583    slot: U256,
8584    origins: Vec<StorageFetchOrigin>,
8585}
8586
8587#[derive(Clone, Debug)]
8588struct StorageFetchOrigin {
8589    request_id: ResyncId,
8590    target: ResyncTarget,
8591}
8592
8593#[derive(Clone, Debug)]
8594struct StorageFetchGroup {
8595    block: ResyncBlock,
8596    slots: Vec<StorageFetchSlot>,
8597    seen: HashSet<(Address, U256)>,
8598}
8599
8600/// One account-target resync collected during request scanning, resolved through
8601/// the account proof fetcher after storage groups are processed.
8602#[derive(Clone, Debug)]
8603struct AccountResyncTarget {
8604    request_id: ResyncId,
8605    block: ResyncBlock,
8606    address: Address,
8607    fields: AccountFieldMask,
8608}
8609
8610fn resolve_trace_resyncs(
8611    cache: &EvmCache,
8612    storage_groups: &mut Vec<StorageFetchGroup>,
8613    account_targets: &mut Vec<AccountResyncTarget>,
8614    state_updates: &mut Vec<StateUpdate>,
8615) {
8616    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8617        return;
8618    };
8619
8620    let mut blocks = Vec::new();
8621    let mut seen = HashSet::new();
8622    for block in storage_groups
8623        .iter()
8624        .map(|group| group.block.clone())
8625        .chain(account_targets.iter().map(|target| target.block.clone()))
8626    {
8627        if seen.insert(block.clone()) {
8628            blocks.push(block);
8629        }
8630    }
8631
8632    let mut traces = HashMap::new();
8633    for block in blocks {
8634        match (fetcher)(resync_block_to_block_id(&block)) {
8635            Ok(diff) => {
8636                traces.insert(block, diff);
8637            }
8638            Err(error) => {
8639                tracing::debug!(
8640                    block = ?block,
8641                    error = %error,
8642                    "block trace resync source failed; falling back to point resync"
8643                );
8644            }
8645        }
8646    }
8647
8648    for group in storage_groups.iter_mut() {
8649        let Some(trace) = traces.get(&group.block) else {
8650            continue;
8651        };
8652        group.slots.retain(|slot| {
8653            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8654                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8655                return false;
8656            }
8657            cache
8658                .cached_storage_value(slot.address, slot.slot)
8659                .is_none()
8660        });
8661        group.seen = group
8662            .slots
8663            .iter()
8664            .map(|slot| (slot.address, slot.slot))
8665            .collect();
8666    }
8667    storage_groups.retain(|group| !group.slots.is_empty());
8668
8669    let mut unresolved_accounts = Vec::new();
8670    for mut account in account_targets.drain(..) {
8671        let Some(trace) = traces.get(&account.block) else {
8672            unresolved_accounts.push(account);
8673            continue;
8674        };
8675        let Some(trace_account) = trace
8676            .accounts
8677            .iter()
8678            .find(|diff| diff.address == account.address)
8679        else {
8680            unresolved_accounts.push(account);
8681            continue;
8682        };
8683
8684        let mut patch = AccountPatch::default();
8685        let mut unresolved = AccountFieldMask::default();
8686        if account.fields.balance {
8687            if let Some(balance) = trace_account.balance {
8688                patch = patch.balance(balance);
8689            } else {
8690                unresolved.balance = true;
8691            }
8692        }
8693        if account.fields.nonce {
8694            if let Some(nonce) = trace_account.nonce {
8695                patch = patch.nonce(nonce);
8696            } else {
8697                unresolved.nonce = true;
8698            }
8699        }
8700        if account.fields.code {
8701            if let Some(code) = &trace_account.code {
8702                patch = patch.code(code.clone());
8703            } else {
8704                unresolved.code = true;
8705            }
8706        }
8707
8708        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
8709            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8710        }
8711        if !account_field_mask_empty(unresolved) {
8712            account.fields = unresolved;
8713            unresolved_accounts.push(account);
8714        }
8715    }
8716    *account_targets = unresolved_accounts;
8717}
8718
8719fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
8720    trace
8721        .accounts
8722        .iter()
8723        .find(|account| account.address == address)
8724        .and_then(|account| {
8725            account
8726                .storage
8727                .iter()
8728                .find(|entry| entry.slot == slot)
8729                .map(|entry| entry.value)
8730        })
8731}
8732
8733fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
8734    !mask.balance && !mask.nonce && !mask.code
8735}
8736
8737fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
8738    let mut failed = Vec::new();
8739    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
8740    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
8741
8742    for request in requests {
8743        for target in &request.targets {
8744            match target {
8745                ResyncTarget::StorageSlot { address, slot } => {
8746                    push_storage_resync_slot(
8747                        &mut storage_groups,
8748                        &request.id,
8749                        &request.block,
8750                        *address,
8751                        *slot,
8752                    );
8753                }
8754                ResyncTarget::StorageSlots { address, slots } => {
8755                    for slot in slots {
8756                        push_storage_resync_slot(
8757                            &mut storage_groups,
8758                            &request.id,
8759                            &request.block,
8760                            *address,
8761                            *slot,
8762                        );
8763                    }
8764                }
8765                ResyncTarget::Account { address, fields } => {
8766                    account_targets.push(AccountResyncTarget {
8767                        request_id: request.id.clone(),
8768                        block: request.block.clone(),
8769                        address: *address,
8770                        fields: *fields,
8771                    });
8772                }
8773            }
8774        }
8775    }
8776
8777    let mut state_updates = Vec::new();
8778    resolve_trace_resyncs(
8779        cache,
8780        &mut storage_groups,
8781        &mut account_targets,
8782        &mut state_updates,
8783    );
8784
8785    if !storage_groups.is_empty() {
8786        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
8787            for group in storage_groups {
8788                let block = group.block.clone();
8789                let fetches: Vec<(Address, U256)> = group
8790                    .slots
8791                    .iter()
8792                    .map(|slot| (slot.address, slot.slot))
8793                    .collect();
8794                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
8795                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
8796                    .slots
8797                    .iter()
8798                    .cloned()
8799                    .map(|slot| ((slot.address, slot.slot), slot))
8800                    .collect();
8801
8802                for (address, slot, fetched) in results {
8803                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
8804                        continue;
8805                    };
8806                    match fetched {
8807                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
8808                        Err(error) => {
8809                            let message = error.to_string();
8810                            push_resync_failures(
8811                                &mut failed,
8812                                &block,
8813                                requested_slot.origins,
8814                                ResyncFailureKind::StorageFetchFailed,
8815                                message,
8816                            );
8817                        }
8818                    }
8819                }
8820
8821                for requested_slot in group.slots {
8822                    if pending
8823                        .remove(&(requested_slot.address, requested_slot.slot))
8824                        .is_some()
8825                    {
8826                        push_resync_failures(
8827                            &mut failed,
8828                            &block,
8829                            requested_slot.origins,
8830                            ResyncFailureKind::StorageFetchOmitted,
8831                            "storage batch fetcher did not return a value for slot".to_string(),
8832                        );
8833                    }
8834                }
8835            }
8836        } else {
8837            for group in storage_groups {
8838                let block = group.block.clone();
8839                for slot in group.slots {
8840                    push_resync_failures(
8841                        &mut failed,
8842                        &block,
8843                        slot.origins,
8844                        ResyncFailureKind::MissingStorageFetcher,
8845                        "storage resync requires a storage batch fetcher".to_string(),
8846                    );
8847                }
8848            }
8849        }
8850    }
8851
8852    if !account_targets.is_empty() {
8853        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
8854            // ONE seam invocation per distinct resync block (targets may pin
8855            // different blocks): eth_getProof is single-address at the RPC
8856            // level, so batching the addresses lets the fetcher fan the
8857            // requests out concurrently instead of paying one round trip per
8858            // account. Root-only probes: account fields need no storage keys.
8859            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
8860            for account in account_targets {
8861                let block_id = resync_block_to_block_id(&account.block);
8862                match groups
8863                    .iter_mut()
8864                    .find(|(group_block, _)| *group_block == block_id)
8865                {
8866                    Some((_, group)) => group.push(account),
8867                    None => groups.push((block_id, vec![account])),
8868                }
8869            }
8870            for (block_id, group) in groups {
8871                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
8872                    group
8873                        .iter()
8874                        .map(|account| (account.address, vec![]))
8875                        .collect(),
8876                    block_id,
8877                )
8878                .into_iter()
8879                .collect();
8880                for account in group {
8881                    // `get` + clone rather than `remove`: two targets for the
8882                    // same address in one group must both resolve from the
8883                    // single probe.
8884                    match probes.get(&account.address).cloned() {
8885                        Some(Ok(proof)) => {
8886                            // Build an authoritative account update from the requested
8887                            // field mask. Use the MATERIALIZING `account_upsert` so a
8888                            // resync applies even to a cold account (a partial `Account`
8889                            // patch on a cold address is silently skipped).
8890                            let mut patch = AccountPatch::default();
8891                            if account.fields.balance {
8892                                patch = patch.balance(proof.balance);
8893                            }
8894                            if account.fields.nonce {
8895                                patch = patch.nonce(proof.nonce);
8896                            }
8897                            // Note: `AccountProof` carries `code_hash`, not code bytes;
8898                            // the `eth_getProof` seam cannot supply runtime code, so a
8899                            // code-field resync is a no-op here (code freshness is
8900                            // handled by a later wave). We still materialize the account
8901                            // so requested balance/nonce fields take effect.
8902                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8903                        }
8904                        Some(Err(error)) => {
8905                            failed.push(ResyncFailure {
8906                                request_id: account.request_id,
8907                                block: account.block,
8908                                target: ResyncTarget::Account {
8909                                    address: account.address,
8910                                    fields: account.fields,
8911                                },
8912                                kind: ResyncFailureKind::AccountFetchFailed,
8913                                message: error.to_string(),
8914                            });
8915                        }
8916                        None => {
8917                            failed.push(ResyncFailure {
8918                                request_id: account.request_id,
8919                                block: account.block,
8920                                target: ResyncTarget::Account {
8921                                    address: account.address,
8922                                    fields: account.fields,
8923                                },
8924                                kind: ResyncFailureKind::AccountFetchOmitted,
8925                                message:
8926                                    "account proof fetcher did not return a result for address"
8927                                        .to_string(),
8928                            });
8929                        }
8930                    }
8931                }
8932            }
8933        } else {
8934            for account in account_targets {
8935                failed.push(ResyncFailure {
8936                    request_id: account.request_id,
8937                    block: account.block,
8938                    target: ResyncTarget::Account {
8939                        address: account.address,
8940                        fields: account.fields,
8941                    },
8942                    kind: ResyncFailureKind::MissingAccountFetcher,
8943                    message: "account resync requires an account proof fetcher".to_string(),
8944                });
8945            }
8946        }
8947    }
8948
8949    let diff = if state_updates.is_empty() {
8950        StateDiff::default()
8951    } else {
8952        cache.apply_updates(&state_updates)
8953    };
8954
8955    ResyncReport {
8956        requested: requests.to_vec(),
8957        state_updates,
8958        diff,
8959        failed,
8960    }
8961}
8962
8963fn push_resync_failures(
8964    failed: &mut Vec<ResyncFailure>,
8965    block: &ResyncBlock,
8966    origins: Vec<StorageFetchOrigin>,
8967    kind: ResyncFailureKind,
8968    message: String,
8969) {
8970    for origin in origins {
8971        failed.push(ResyncFailure {
8972            request_id: origin.request_id,
8973            block: block.clone(),
8974            target: origin.target,
8975            kind,
8976            message: message.clone(),
8977        });
8978    }
8979}
8980
8981fn push_storage_resync_slot(
8982    groups: &mut Vec<StorageFetchGroup>,
8983    request_id: &ResyncId,
8984    block: &ResyncBlock,
8985    address: Address,
8986    slot: U256,
8987) {
8988    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
8989        index
8990    } else {
8991        groups.push(StorageFetchGroup {
8992            block: block.clone(),
8993            slots: Vec::new(),
8994            seen: HashSet::new(),
8995        });
8996        groups.len() - 1
8997    };
8998
8999    let group = &mut groups[group_index];
9000    let origin = StorageFetchOrigin {
9001        request_id: request_id.clone(),
9002        target: ResyncTarget::StorageSlot { address, slot },
9003    };
9004    if group.seen.insert((address, slot)) {
9005        group.slots.push(StorageFetchSlot {
9006            address,
9007            slot,
9008            origins: vec![origin],
9009        });
9010    } else if let Some(existing) = group
9011        .slots
9012        .iter_mut()
9013        .find(|existing| existing.address == address && existing.slot == slot)
9014    {
9015        existing.origins.push(origin);
9016    }
9017}
9018
9019fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
9020    match block {
9021        ResyncBlock::Latest => BlockId::latest(),
9022        ResyncBlock::Pending => BlockId::pending(),
9023        ResyncBlock::Safe => BlockId::safe(),
9024        ResyncBlock::Finalized => BlockId::finalized(),
9025        ResyncBlock::Number(number) => BlockId::number(*number),
9026        ResyncBlock::Hash {
9027            number: _,
9028            hash,
9029            require_canonical,
9030        } => BlockId::from((*hash, Some(*require_canonical))),
9031    }
9032}
9033
9034impl<N: Network> RegisteredHandler<N> {
9035    fn matches(&self, input: &ReactiveInput<N>) -> bool {
9036        self.interests
9037            .iter()
9038            .any(|interest| interest_matches(interest, input))
9039    }
9040
9041    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
9042        self.interests.iter().find_map(|interest| match interest {
9043            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
9044                handler_id: self.id.clone(),
9045                route_key: interest.route_key(log),
9046            }),
9047            ReactiveInterest::Logs(_)
9048            | ReactiveInterest::Blocks(_)
9049            | ReactiveInterest::PendingTransactions(_) => None,
9050        })
9051    }
9052}
9053
9054fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
9055    let mut candidate = next.clone();
9056    let mut insertion_index = filters.len();
9057    let mut index = 0;
9058    while index < filters.len() {
9059        if filters[index].block_option != candidate.block_option {
9060            index += 1;
9061            continue;
9062        }
9063        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
9064            candidate = merged;
9065            insertion_index = insertion_index.min(index);
9066            filters.remove(index);
9067            index = 0;
9068        } else {
9069            index += 1;
9070        }
9071    }
9072    filters.insert(insertion_index.min(filters.len()), candidate);
9073}
9074
9075fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
9076    if filter_subsumes(left, right) {
9077        return Some(left.clone());
9078    }
9079    if filter_subsumes(right, left) {
9080        return Some(right.clone());
9081    }
9082    let differing_dimensions = usize::from(left.address != right.address)
9083        + left
9084            .topics
9085            .iter()
9086            .zip(right.topics.iter())
9087            .filter(|(left, right)| left != right)
9088            .count();
9089    if differing_dimensions != 1 {
9090        return None;
9091    }
9092
9093    let mut merged = left.clone();
9094    if merged.address != right.address {
9095        merge_filter_set(&mut merged.address, &right.address);
9096    } else {
9097        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
9098            if merged_topic != right_topic {
9099                merge_filter_set(merged_topic, right_topic);
9100                break;
9101            }
9102        }
9103    }
9104    Some(merged)
9105}
9106
9107fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
9108    filter_set_subsumes(&left.address, &right.address)
9109        && left
9110            .topics
9111            .iter()
9112            .zip(right.topics.iter())
9113            .all(|(left, right)| filter_set_subsumes(left, right))
9114}
9115
9116fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
9117    left.is_empty()
9118        || (!right.is_empty()
9119            && right
9120                .iter()
9121                .all(|value| left.iter().any(|known| known == value)))
9122}
9123
9124fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
9125    if target.is_empty() {
9126        return;
9127    }
9128    if source.is_empty() {
9129        *target = FilterSet::default();
9130        return;
9131    }
9132    for value in source.iter() {
9133        target.insert(value.clone());
9134    }
9135}
9136
9137#[derive(Clone, Debug)]
9138struct HandlerExecution {
9139    handler_id: HandlerId,
9140    quality: StateEffectQuality,
9141    tags: Vec<ReportTag>,
9142    state_updates: Vec<StateUpdate>,
9143    invalidations: Vec<InvalidationRequest>,
9144    resyncs: Vec<ResyncRequest>,
9145    speculative: Vec<SpeculativeRequest>,
9146    hook_signals: Vec<HookSignal>,
9147}
9148
9149impl HandlerExecution {
9150    fn from_outcome(
9151        handler_id: HandlerId,
9152        input_ref: InputRef,
9153        outcome: HandlerOutcome,
9154        preconfirmed: bool,
9155    ) -> Self {
9156        let mut state_updates = Vec::new();
9157        let mut invalidations = Vec::new();
9158        let mut resyncs = Vec::new();
9159        let mut speculative = Vec::new();
9160        let mut hook_signals = Vec::new();
9161
9162        for effect in outcome.effects {
9163            match effect {
9164                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
9165                ReactiveEffect::Invalidate(invalidation) => {
9166                    state_updates.push(StateUpdate::purge(
9167                        invalidation.address,
9168                        invalidation.scope.clone(),
9169                    ));
9170                    invalidations.push(invalidation);
9171                }
9172                ReactiveEffect::Resync(mut request) => {
9173                    if preconfirmed {
9174                        request.block = ResyncBlock::Pending;
9175                    }
9176                    resyncs.push(request);
9177                }
9178                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
9179                ReactiveEffect::Speculative(mut request) => {
9180                    request.input_ref = input_ref;
9181                    speculative.push(request);
9182                }
9183            }
9184        }
9185
9186        Self {
9187            handler_id,
9188            quality: outcome.quality,
9189            tags: outcome.tags,
9190            state_updates,
9191            invalidations,
9192            resyncs,
9193            speculative,
9194            hook_signals,
9195        }
9196    }
9197}
9198
9199fn dedupe_records<N: Network>(
9200    records: Vec<ReactiveInputRecord<N>>,
9201) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
9202    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
9203    let mut deduped = Vec::with_capacity(records.len());
9204    for record in records {
9205        let identity = record.validated_identity()?;
9206        if !record.is_payload_deduplicable() {
9207            deduped.push(record);
9208            continue;
9209        }
9210        if let Some(index) = positions.get(&identity).copied() {
9211            let merged = deduped[index].merge_compatible_duplicate(&record)?;
9212            debug_assert!(merged, "same indexed identity is deduplicable");
9213        } else {
9214            positions.insert(identity, deduped.len());
9215            deduped.push(record);
9216        }
9217    }
9218    Ok(deduped)
9219}
9220
9221fn dedupe_scoped_records<N: Network>(
9222    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9223) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
9224    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
9225    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
9226        Vec::with_capacity(records.len());
9227    for (record, audience, delivery_scope) in records {
9228        let identity = record.validated_identity()?;
9229        if !record.is_payload_deduplicable() {
9230            deduped.push((record, audience, delivery_scope));
9231            continue;
9232        }
9233        if let Some(index) = positions.get(&identity).copied() {
9234            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
9235            debug_assert!(merged, "same indexed identity is deduplicable");
9236            merge_delivery_audience(&mut deduped[index].1, audience);
9237            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
9238        } else {
9239            positions.insert(identity, deduped.len());
9240            deduped.push((record, audience, delivery_scope));
9241        }
9242    }
9243    Ok(deduped)
9244}
9245
9246fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
9247    *into = match (*into, incoming) {
9248        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
9249        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
9250            DeliveryScope::CanonicalProgress
9251        }
9252        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
9253        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
9254        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
9255        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
9256    };
9257}
9258
9259fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
9260    match (&mut *into, incoming) {
9261        (DeliveryAudience::All, _) => {}
9262        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
9263        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
9264            for owner in incoming {
9265                if !current.contains(&owner) {
9266                    current.push(owner);
9267                }
9268            }
9269        }
9270        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9271            current.retain(|owner| incoming.contains(owner));
9272        }
9273        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9274            excluded.retain(|owner| !included.contains(owner));
9275        }
9276        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9277            let DeliveryAudience::Owners(included) = current else {
9278                unreachable!("match arm restricts the audience variant")
9279            };
9280            excluded.retain(|owner| !included.contains(owner));
9281            *current = DeliveryAudience::AllExcept(excluded);
9282        }
9283    }
9284}
9285
9286fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9287    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9288        records.into_iter().enumerate().collect();
9289    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9290    indexed.into_iter().map(|(_, record)| record).collect()
9291}
9292
9293fn sort_scoped_records<N: Network>(
9294    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9295) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9296    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9297    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9298    indexed
9299        .into_iter()
9300        .map(|(_, scoped_record)| scoped_record)
9301        .collect()
9302}
9303
9304fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9305    if let Some((block, _)) = reorg_signal_block(record) {
9306        return RecordSortKey {
9307            class: 0,
9308            block_number: block.number,
9309            record_class: 0,
9310            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9311            log_index: record.context.log_index.unwrap_or(u64::MAX),
9312            original_index: index,
9313        };
9314    }
9315    if is_canonical_status(&record.context.chain_status)
9316        && let Some(block) = record.context.block.as_ref()
9317    {
9318        let (record_class, transaction_index, log_index) = match &record.input {
9319            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9320            ReactiveInput::Log(log) if !log.removed => (
9321                1,
9322                log.transaction_index
9323                    .or(record.context.transaction_index)
9324                    .unwrap_or(u64::MAX),
9325                log.log_index
9326                    .or(record.context.log_index)
9327                    .unwrap_or(u64::MAX),
9328            ),
9329            ReactiveInput::Log(_)
9330            | ReactiveInput::PendingTxHash(_)
9331            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9332        };
9333        return RecordSortKey {
9334            class: 1,
9335            block_number: block.number,
9336            record_class,
9337            transaction_index,
9338            log_index,
9339            original_index: index,
9340        };
9341    }
9342
9343    RecordSortKey {
9344        class: 2,
9345        block_number: 0,
9346        record_class: 0,
9347        transaction_index: 0,
9348        log_index: 0,
9349        original_index: index,
9350    }
9351}
9352
9353#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9354struct RecordSortKey {
9355    class: u8,
9356    block_number: u64,
9357    record_class: u8,
9358    transaction_index: u64,
9359    log_index: u64,
9360    original_index: usize,
9361}
9362
9363fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9364    match (interest, input) {
9365        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9366        (
9367            ReactiveInterest::Blocks(BlockInterest {
9368                mode: BlockInterestMode::Header,
9369            }),
9370            ReactiveInput::BlockHeader(_),
9371        ) => true,
9372        (
9373            ReactiveInterest::Blocks(BlockInterest {
9374                mode: BlockInterestMode::FullBlock,
9375            }),
9376            ReactiveInput::FullBlock(_),
9377        ) => true,
9378        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9379            interest.matches_hash_only()
9380        }
9381        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9382            interest.matches_tx(tx)
9383        }
9384        _ => false,
9385    }
9386}
9387
9388fn validate_effects(
9389    input_ref: InputRef,
9390    ctx: &ReactiveContext,
9391    handler_id: &HandlerId,
9392    effects: &[ReactiveEffect],
9393) -> Result<(), ReactiveError> {
9394    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9395        || matches!(input_ref, InputRef::PendingTx { .. });
9396    if !pending {
9397        return Ok(());
9398    }
9399
9400    for effect in effects {
9401        let effect_kind = match effect {
9402            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9403            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9404            ReactiveEffect::Resync(_) => Some("resync"),
9405            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9406        };
9407        if let Some(effect_kind) = effect_kind {
9408            return Err(ReactiveError::InvalidPendingEffect {
9409                input_ref: Box::new(input_ref),
9410                handler_id: handler_id.clone(),
9411                effect_kind,
9412            });
9413        }
9414    }
9415    Ok(())
9416}
9417
9418fn detect_conflicts(
9419    input_ref: InputRef,
9420    executions: &[HandlerExecution],
9421) -> Result<(), ReactiveError> {
9422    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9423    for execution in executions {
9424        for update in &execution.state_updates {
9425            for (target, value) in absolute_writes(update) {
9426                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9427                    if previous_value != &value {
9428                        return Err(ReactiveError::ConflictingEffects {
9429                            input_ref: Box::new(input_ref),
9430                            target: Box::new(target),
9431                            first: previous_handler.clone(),
9432                            second: execution.handler_id.clone(),
9433                        });
9434                    }
9435                } else {
9436                    writes.insert(target, (value, execution.handler_id.clone()));
9437                }
9438            }
9439        }
9440    }
9441    Ok(())
9442}
9443
9444fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9445    match update {
9446        StateUpdate::Slot {
9447            address,
9448            slot,
9449            value,
9450        } => vec![(
9451            EffectTarget::StorageSlot {
9452                address: *address,
9453                slot: *slot,
9454            },
9455            AbsoluteValue::U256(*value),
9456        )],
9457        StateUpdate::SlotMasked {
9458            address,
9459            slot,
9460            mask,
9461            value,
9462        } => vec![(
9463            EffectTarget::MaskedStorageSlot {
9464                address: *address,
9465                slot: *slot,
9466                mask: *mask,
9467            },
9468            AbsoluteValue::U256(*value),
9469        )],
9470        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9471            account_patch_writes(*address, patch)
9472        }
9473        StateUpdate::SlotDelta { .. }
9474        | StateUpdate::BalanceDelta { .. }
9475        | StateUpdate::Purge { .. } => Vec::new(),
9476    }
9477}
9478
9479fn account_patch_writes(
9480    address: Address,
9481    patch: &AccountPatch,
9482) -> Vec<(EffectTarget, AbsoluteValue)> {
9483    let mut writes = Vec::new();
9484    if let Some(balance) = patch.balance {
9485        writes.push((
9486            EffectTarget::AccountBalance { address },
9487            AbsoluteValue::U256(balance),
9488        ));
9489    }
9490    if let Some(nonce) = patch.nonce {
9491        writes.push((
9492            EffectTarget::AccountNonce { address },
9493            AbsoluteValue::U64(nonce),
9494        ));
9495    }
9496    if let Some(code) = &patch.code {
9497        writes.push((
9498            EffectTarget::AccountCode { address },
9499            AbsoluteValue::Bytes(code.clone()),
9500        ));
9501    }
9502    writes
9503}
9504
9505fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9506    match input {
9507        ReactiveInput::Log(log) => InputRef::Log {
9508            chain_id: ctx.chain_id,
9509            block_hash: log
9510                .block_hash
9511                .or(ctx.block.as_ref().map(|block| block.hash))
9512                .unwrap_or_default(),
9513            transaction_hash: log.transaction_hash.unwrap_or_default(),
9514            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9515        },
9516        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9517            chain_id: ctx.chain_id,
9518            hash: *hash,
9519        },
9520        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9521            chain_id: ctx.chain_id,
9522            hash: tx.tx_hash(),
9523        },
9524        ReactiveInput::BlockHeader(header) => InputRef::Block {
9525            chain_id: ctx.chain_id,
9526            hash: header.hash(),
9527            number: header.number(),
9528        },
9529        ReactiveInput::FullBlock(block) => {
9530            let header = block.header();
9531            InputRef::Block {
9532                chain_id: ctx.chain_id,
9533                hash: header.hash(),
9534                number: header.number(),
9535            }
9536        }
9537    }
9538}
9539
9540fn is_canonical_status(status: &ChainStatus) -> bool {
9541    matches!(
9542        status,
9543        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9544    )
9545}
9546
9547/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9548pub struct EventDecoderHandler {
9549    id: HandlerId,
9550    decoder: Arc<dyn EventDecoder>,
9551    interest: LogInterest,
9552}
9553
9554impl EventDecoderHandler {
9555    /// Create an adapter from a decoder and log interest.
9556    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9557        Self {
9558            id,
9559            decoder,
9560            interest,
9561        }
9562    }
9563}
9564
9565impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9566    fn id(&self) -> HandlerId {
9567        self.id.clone()
9568    }
9569
9570    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9571        vec![ReactiveInterest::Logs(self.interest.clone())]
9572    }
9573
9574    fn handle(
9575        &self,
9576        _ctx: &ReactiveContext,
9577        input: &ReactiveInput<N>,
9578        state: &dyn StateView,
9579    ) -> Result<HandlerOutcome, HandlerError> {
9580        let ReactiveInput::Log(log) = input else {
9581            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9582        };
9583
9584        Ok(HandlerOutcome {
9585            effects: self
9586                .decoder
9587                .decode(&log.inner, state)
9588                .into_iter()
9589                .map(ReactiveEffect::StateUpdate)
9590                .collect(),
9591            quality: StateEffectQuality::ExactFromInput,
9592            tags: Vec::new(),
9593        })
9594    }
9595}
9596
9597/// One independently negotiable event-subscriber behavior.
9598#[derive(
9599    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9600)]
9601#[non_exhaustive]
9602pub enum SubscriberCapability {
9603    /// Emit EVM logs.
9604    Logs,
9605    /// Emit block headers.
9606    BlockHeaders,
9607    /// Emit full blocks with transaction bodies.
9608    FullBlocks,
9609    /// Emit pending transaction hashes.
9610    PendingTransactionHashes,
9611    /// Emit hydrated pending transactions.
9612    PendingTransactions,
9613    /// Fetch historical data from a caller-selected anchor.
9614    HistoricalBackfill,
9615    /// Follow live chain data.
9616    Live,
9617    /// Recover the complete committed consumer position after reconnect or
9618    /// restart, including any unacknowledged delivery.
9619    ///
9620    /// An implementation may satisfy this with native stream replay or with a
9621    /// durable cursor plus deterministic historical reconciliation of an
9622    /// ephemeral live child. The end-to-end subscriber must still prove there
9623    /// is no gap between the restored position and resumed live delivery. If an
9624    /// old delivery token is emitted again, that token must identify the same
9625    /// immutable delivery and pass the engine's witness check.
9626    DurableReplay,
9627    /// Preserve logical handler ownership on delivered batches.
9628    OwnerScopedDelivery,
9629    /// Add and remove interests without replacing the complete session.
9630    DynamicInterests,
9631    /// Emit explicit canonical branch transitions.
9632    ExplicitReorgs,
9633    /// Emit safe and finalized head updates.
9634    FinalityUpdates,
9635    /// Emit ordered synchronization or source-cutover barriers.
9636    Barriers,
9637    /// Emit sequencer pre-confirmations into a disposable state overlay.
9638    Preconfirmations,
9639}
9640
9641/// Capability set advertised by an [`EventSubscriber`].
9642///
9643/// The default is deliberately empty: callers can safely reject a topology
9644/// when an older or minimal implementation has not opted into a required
9645/// behavior.
9646#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9647pub struct SubscriberCapabilities {
9648    supported: BTreeSet<SubscriberCapability>,
9649}
9650
9651impl SubscriberCapabilities {
9652    /// Construct a capability set from supported behaviors.
9653    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9654        Self {
9655            supported: capabilities.into_iter().collect(),
9656        }
9657    }
9658
9659    /// Test one independently negotiable behavior.
9660    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9661        self.supported.contains(&capability)
9662    }
9663
9664    /// Iterate supported behaviors in stable order.
9665    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
9666        self.supported.iter().copied()
9667    }
9668
9669    /// Whether the subscriber follows live chain data.
9670    pub fn supports_live(&self) -> bool {
9671        self.supports(SubscriberCapability::Live)
9672    }
9673
9674    /// Whether the subscriber can durably recover its committed position and
9675    /// any unacknowledged delivery without an event gap.
9676    pub fn supports_durable_replay(&self) -> bool {
9677        self.supports(SubscriberCapability::DurableReplay)
9678    }
9679
9680    /// Whether the subscriber emits explicit branch transitions.
9681    pub fn supports_explicit_reorgs(&self) -> bool {
9682        self.supports(SubscriberCapability::ExplicitReorgs)
9683    }
9684}
9685
9686impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
9687    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
9688        Self::new(iter)
9689    }
9690}
9691
9692/// Provider-agnostic subscriber interface.
9693pub trait EventSubscriber<N: Network = Ethereum>: Send {
9694    /// Chain identity attached to emitted records, when it has been resolved.
9695    ///
9696    /// Remote and provider-backed subscribers should cache one authoritative
9697    /// identity before exposing input. Returning `None` is reserved for
9698    /// synthetic or genuinely chain-agnostic subscribers; composite sources
9699    /// can use this hook to reject accidentally mixed networks.
9700    fn chain_id(&self) -> Option<u64> {
9701        None
9702    }
9703
9704    /// Behaviors this subscriber can uphold for topology validation.
9705    fn capabilities(&self) -> SubscriberCapabilities {
9706        SubscriberCapabilities::default()
9707    }
9708
9709    /// Replace all interests registered with the subscriber.
9710    ///
9711    /// Implementations may use this as a full setup/reset operation. The
9712    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
9713    /// delivery/dedupe bookkeeping when this method is called.
9714    ///
9715    /// The returned operation must complete only after the replacement has
9716    /// committed to the subscriber's desired state. Remote implementations can
9717    /// use this asynchronous boundary to wait for an authoritative service-side
9718    /// acknowledgement before returning `Ok(())`. On error, or when the future
9719    /// is dropped before completion, the previously committed desired state
9720    /// must remain authoritative (or be reconciled before later delivery can
9721    /// expose the uncommitted change) so callers can safely retry.
9722    ///
9723    /// # Errors
9724    ///
9725    /// The returned operation reports [`SubscriberError`] when the replacement
9726    /// cannot be validated or committed by the underlying source.
9727    fn register_interests(
9728        &mut self,
9729        interests: &[ReactiveInterest<N>],
9730    ) -> SubscriberOperation<'_, ()>;
9731
9732    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
9733    ///
9734    /// The returned future must be cancellation-safe: dropping it while pending
9735    /// must not discard a complete input that a later call could otherwise
9736    /// deliver. Composite subscribers use this property to race historical and
9737    /// live sources without dedicating a task to each transport.
9738    ///
9739    /// # Errors
9740    ///
9741    /// The returned future reports [`SubscriberError`] for transport,
9742    /// continuity, decoding, or source-resource failures.
9743    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
9744
9745    /// Restore the subscriber's committed position before polling resumes.
9746    ///
9747    /// The engine invokes this synchronously from
9748    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
9749    /// recovery state and before publishing that state as resumed. Implementations
9750    /// should validate that provider/service cursors cannot regress and seed any
9751    /// source epoch or overlap history required for safe replay. A composite may
9752    /// rebuild an ephemeral live child from `coverage_head` plus historical
9753    /// reconciliation rather than require that child to replay bytes itself, but
9754    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
9755    /// complete restore closes that cutover gap before exposing live input. On
9756    /// error, either
9757    /// the prior position must remain authoritative, or the subscriber may retain
9758    /// this *exact* restore as pending intent; in the latter case it must block
9759    /// delivery and reject conflicting restores until retry/reconciliation commits
9760    /// the same position. This permits synchronous adapters over durable remote
9761    /// state without exposing a half-restored stream.
9762    ///
9763    /// # Errors
9764    ///
9765    /// Returns [`SubscriberError`] when the position is invalid, regresses or
9766    /// conflicts with committed source state, or cannot be restored durably.
9767    fn restore_position(
9768        &mut self,
9769        _position: &SubscriberResumePosition,
9770    ) -> Result<(), SubscriberError> {
9771        Ok(())
9772    }
9773
9774    /// Commit a subscriber-owned delivery token after runtime ingestion.
9775    ///
9776    /// Ephemeral subscribers can rely on this no-op default. Durable remote
9777    /// subscribers should make acknowledgement idempotent because cancellation
9778    /// or transport failure can cause a successfully ingested batch to replay.
9779    /// Re-emitting a token must reproduce the same immutable records, routing,
9780    /// chain controls, chain identity, and provider checkpoint; the checkpointed
9781    /// engine verifies its persisted delivery witness before skipping ingestion.
9782    ///
9783    /// # Errors
9784    ///
9785    /// The returned operation reports [`SubscriberError`] when the delivery
9786    /// token cannot be committed idempotently by the source.
9787    fn acknowledge_delivery(
9788        &mut self,
9789        _token: SubscriberDeliveryToken,
9790    ) -> SubscriberOperation<'_, ()> {
9791        Box::pin(async { Ok(()) })
9792    }
9793}
9794
9795/// Boxed, sendable future returned by subscriber lifecycle operations.
9796///
9797/// The output is generic so the same type can represent registration, removal,
9798/// and future acknowledgement values without requiring an async-trait helper.
9799pub type SubscriberOperation<'a, T> =
9800    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
9801
9802/// Boxed future returned by [`EventSubscriber::next_batch`].
9803pub type SubscriberNextBatch<'a, N> = Pin<
9804    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
9805>;
9806
9807/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
9808pub type SubscriberNextScopedBatch<'a, N> = Pin<
9809    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
9810>;
9811
9812/// Subscriber mode requested for the Alloy subscriber.
9813#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9814pub enum SubscriberMode {
9815    /// Prefer the default compiled transport.
9816    ///
9817    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
9818    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
9819    /// the opt-in `reactive-polling` feature is enabled.
9820    #[default]
9821    Auto,
9822    /// Use provider pubsub streams.
9823    PubSub,
9824    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
9825    Polling,
9826}
9827
9828#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9829enum FlashblocksAdapter {
9830    NativeSubscriptions,
9831    PendingStatePolling,
9832}
9833
9834fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
9835    match chain_id {
9836        8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
9837        10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
9838        _ => None,
9839    }
9840}
9841
9842/// Subscriber configuration.
9843#[derive(Clone, Debug, PartialEq, Eq)]
9844pub struct SubscriberConfig {
9845    /// Flashblocks delivery policy. Provider support itself is configured by
9846    /// the transport's single `flashblocks` endpoint flag.
9847    pub preconfirmations: PreconfirmationMode,
9848    /// Cadence for certifying sealed canonical heads while connected to a
9849    /// Flashblocks endpoint whose `newHeads` stream may contain partial heads.
9850    pub canonical_head_poll_interval: Duration,
9851    /// Optimism pending-state sampling cadence.
9852    ///
9853    /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers
9854    /// currently expose the interoperable Flashblocks surface through
9855    /// `pending` RPC reads, so one generation-pinned sampler reads the
9856    /// cumulative pending block, its exact hash-addressed parent, filtered
9857    /// pending-block logs, and bounded exact transaction receipts.
9858    pub flashblock_poll_interval: Duration,
9859    /// Consecutive pending-state request failure allowance.
9860    ///
9861    /// A successful sampling tick resets this counter. Semantic integrity
9862    /// failures, such as non-monotonic transaction membership or malformed
9863    /// logs, are never retried through this allowance.
9864    pub max_consecutive_flashblock_poll_failures: usize,
9865    /// Maximum pending receipts per sampling tick.
9866    ///
9867    /// Receipts are requested by exact transaction hash in one JSON-RPC batch,
9868    /// because separate `eth_getBlockReceipts("pending")` responses can refer
9869    /// to a different cumulative Flashblock. The rolling total-method budget
9870    /// may impose a lower effective per-tick limit; with the defaults and one
9871    /// log filter, at most seven receipts are requested per tick.
9872    pub max_pending_transaction_receipts_per_tick: usize,
9873    /// Pending-state RPC method budget per rolling one-second window.
9874    ///
9875    /// The sampler reserves capacity for the pending-block, exact-parent, and
9876    /// filtered-log methods implied by its cadence and filter plan, plus the
9877    /// exact-parent canonical-head poll when block interests require it. Exact
9878    /// receipt hydration uses only an evenly apportioned remainder. Request
9879    /// timestamps enforce the ceiling across actual ticks, including delayed
9880    /// ticks. The default leaves headroom below common paid-provider limits of
9881    /// 50 requests per second.
9882    pub max_flashblock_rpc_requests_per_second: usize,
9883    /// Hydrate pending transaction hashes into full bodies when possible.
9884    pub hydrate_pending_transactions: bool,
9885    /// Verify each canonical log's block identity through RPC and enrich its
9886    /// context with the exact parent hash before delivery.
9887    ///
9888    /// Enable this when a strict coordinator (such as a hybrid historical/live
9889    /// source) must prove canonical ancestry from log-only pubsub events.
9890    /// Verification is cached per block, so the provider is queried at most
9891    /// once for each distinct canonical block retained in the dedupe window.
9892    /// For high-volume pubsub filters, configure
9893    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
9894    /// provider so verification responses cannot be starved by notifications.
9895    pub verify_log_block_context: bool,
9896    /// Maximum records to emit per batch.
9897    pub max_batch_size: usize,
9898    /// Maximum distinct contract addresses placed in one provider-side log
9899    /// subscription. Compatible logical owner filters are fanned into address
9900    /// supersets up to this limit; exact owner routing still happens locally.
9901    pub max_log_addresses_per_subscription: usize,
9902    /// Maximum records retained across the delivery queue and hidden
9903    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
9904    /// closed until a full interest reset, because dropping an event would
9905    /// create an unknowable continuity gap.
9906    pub max_pending_records: usize,
9907    /// Maximum lazy owner-backfill requests retained at once.
9908    pub max_pending_backfills: usize,
9909    /// Maximum approximate encoded bytes accepted from one historical log
9910    /// response (fixed log identity fields, topics, and data).
9911    pub max_backfill_log_bytes: usize,
9912    /// Maximum provider log requests concurrently in flight during bulk owner
9913    /// reconciliation.
9914    pub max_reconcile_requests_in_flight: usize,
9915    /// Reconnect policy for WebSocket/pubsub streams.
9916    pub reconnect: SubscriberReconnectConfig,
9917}
9918
9919impl Default for SubscriberConfig {
9920    fn default() -> Self {
9921        Self {
9922            preconfirmations: PreconfirmationMode::Disabled,
9923            canonical_head_poll_interval: Duration::from_millis(500),
9924            flashblock_poll_interval: Duration::from_millis(250),
9925            max_consecutive_flashblock_poll_failures: 10,
9926            max_pending_transaction_receipts_per_tick: 32,
9927            max_flashblock_rpc_requests_per_second: 40,
9928            hydrate_pending_transactions: false,
9929            verify_log_block_context: false,
9930            max_batch_size: 1024,
9931            max_log_addresses_per_subscription: 1024,
9932            max_pending_records: 16_384,
9933            max_pending_backfills: 4_096,
9934            max_backfill_log_bytes: 64 * 1024 * 1024,
9935            max_reconcile_requests_in_flight: 8,
9936            reconnect: SubscriberReconnectConfig::default(),
9937        }
9938    }
9939}
9940
9941/// Provider surface established for one Flashblocks generation.
9942#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9943pub enum FlashblocksDelivery {
9944    /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams.
9945    NativeSubscriptions,
9946    /// Generation-pinned `pending` block and log sampling.
9947    PendingStatePolling,
9948}
9949
9950/// Request/response traffic issued by one Flashblocks subscriber generation.
9951#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9952pub struct FlashblocksRpcMetrics {
9953    capability_requests: u64,
9954    provider_pair_chain_requests: u64,
9955    canonical_head_requests: u64,
9956    pending_block_requests: u64,
9957    pending_log_requests: u64,
9958    pending_receipt_requests: u64,
9959    pending_receipts_completed: u64,
9960    pending_receipts_unavailable: u64,
9961    failed_requests: u64,
9962    raced_samples: u64,
9963}
9964
9965impl FlashblocksRpcMetrics {
9966    /// Opportunistic `op_supportedCapabilities` probes attempted.
9967    pub const fn capability_requests(self) -> u64 {
9968        self.capability_requests
9969    }
9970
9971    /// Chain-identity requests used to verify an explicitly paired
9972    /// pending-state provider against the subscriber's stream provider.
9973    pub const fn provider_pair_chain_requests(self) -> u64 {
9974        self.provider_pair_chain_requests
9975    }
9976
9977    /// Exact parent-block requests used to fence pending and canonical state.
9978    pub const fn canonical_head_requests(self) -> u64 {
9979        self.canonical_head_requests
9980    }
9981
9982    /// Cumulative pending-block requests.
9983    pub const fn pending_block_requests(self) -> u64 {
9984        self.pending_block_requests
9985    }
9986
9987    /// Pending log-filter requests.
9988    pub const fn pending_log_requests(self) -> u64 {
9989        self.pending_log_requests
9990    }
9991
9992    /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash.
9993    /// Several methods may share one JSON-RPC batch transport request.
9994    pub const fn pending_receipt_requests(self) -> u64 {
9995        self.pending_receipt_requests
9996    }
9997
9998    /// Exact pending transaction receipts returned successfully.
9999    pub const fn pending_receipts_completed(self) -> u64 {
10000        self.pending_receipts_completed
10001    }
10002
10003    /// Exact pending transaction receipts that were not materialized yet and remain
10004    /// eligible for retry on the next cumulative sample.
10005    pub const fn pending_receipts_unavailable(self) -> u64 {
10006        self.pending_receipts_unavailable
10007    }
10008
10009    /// Provider request failures observed by a pending-state sampler.
10010    pub const fn failed_requests(self) -> u64 {
10011        self.failed_requests
10012    }
10013
10014    /// Samples discarded because the pending-log response advanced beyond
10015    /// the separately fetched cumulative block. The next tick retries from a
10016    /// fresh block/log pair; no partial speculative view is published.
10017    pub const fn raced_samples(self) -> u64 {
10018        self.raced_samples
10019    }
10020
10021    /// Total request/response calls attributable to Flashblocks qualification
10022    /// and sampling.
10023    pub const fn total_requests(self) -> u64 {
10024        self.capability_requests
10025            .saturating_add(self.provider_pair_chain_requests)
10026            .saturating_add(self.canonical_head_requests)
10027            .saturating_add(self.pending_block_requests)
10028            .saturating_add(self.pending_log_requests)
10029            .saturating_add(self.pending_receipt_requests)
10030    }
10031}
10032
10033/// Successful initial Flashblocks endpoint preflight.
10034///
10035/// This proves chain identity and either subscription acknowledgement for
10036/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, or
10037/// method support for OP's bounded pending block/log sampler. Notification
10038/// liveness and an active-pool pending log remain acceptance-window checks: a
10039/// successful preflight alone must not qualify an endpoint for live trading.
10040#[derive(Clone, Debug, PartialEq, Eq)]
10041pub struct FlashblocksPreflight {
10042    chain_id: u64,
10043    provider: ProviderRef,
10044    delivery: FlashblocksDelivery,
10045    pending_log_subscriptions: usize,
10046    pending_log_filters: usize,
10047    advertised_capabilities: Option<serde_json::Value>,
10048}
10049
10050impl FlashblocksPreflight {
10051    /// Chain identity read from the pinned provider lease.
10052    pub const fn chain_id(&self) -> u64 {
10053        self.chain_id
10054    }
10055
10056    /// Provider generation whose HTTP state and both WebSocket streams were
10057    /// preflighted together.
10058    pub const fn provider(&self) -> &ProviderRef {
10059        &self.provider
10060    }
10061
10062    /// Provider surface selected for this chain.
10063    pub const fn delivery(&self) -> FlashblocksDelivery {
10064        self.delivery
10065    }
10066
10067    /// Number of acknowledged pool-filtered `pendingLogs` subscriptions.
10068    pub const fn pending_log_subscriptions(&self) -> usize {
10069        self.pending_log_subscriptions
10070    }
10071
10072    /// Number of provider-facing pending-log filters covered by the native or
10073    /// sampled delivery surface.
10074    pub const fn pending_log_filters(&self) -> usize {
10075        self.pending_log_filters
10076    }
10077
10078    /// Opaque response from `op_supportedCapabilities`, when the provider
10079    /// implements that optional RPC method.
10080    pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
10081        self.advertised_capabilities.as_ref()
10082    }
10083}
10084
10085/// WebSocket/pubsub reconnect policy.
10086///
10087/// Reconnects are applied after an established subscription stream terminates.
10088/// Initial subscription failures are still returned immediately so deployment
10089/// mistakes, unsupported transports, and bad endpoints fail fast.
10090#[derive(Clone, Debug, PartialEq, Eq)]
10091pub struct SubscriberReconnectConfig {
10092    /// Whether pubsub streams should be recreated after termination.
10093    pub enabled: bool,
10094    /// Delay before the first reconnect attempt.
10095    pub initial_delay: Duration,
10096    /// Delay before the second reconnect attempt. Later retries double this
10097    /// delay up to [`Self::max_delay`].
10098    pub retry_delay: Duration,
10099    /// Maximum delay between reconnect attempts.
10100    pub max_delay: Duration,
10101    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
10102    pub max_attempts: Option<usize>,
10103    /// Number of recently emitted canonical input refs remembered to suppress
10104    /// duplicates across reconnect backfill and subscription replay.
10105    pub dedupe_window: usize,
10106}
10107
10108impl Default for SubscriberReconnectConfig {
10109    fn default() -> Self {
10110        Self {
10111            enabled: true,
10112            initial_delay: Duration::ZERO,
10113            retry_delay: Duration::from_millis(250),
10114            max_delay: Duration::from_secs(30),
10115            max_attempts: Some(3),
10116            dedupe_window: 4096,
10117        }
10118    }
10119}
10120
10121/// Historical log backfill requested when adding subscriber interests.
10122///
10123/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
10124/// pending-transaction interests are live-only. `AlloySubscriber` emits records
10125/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
10126/// owner registration adopts/subscribes the desired live filter first, then
10127/// reconciles history behind that live fence; startup/global replacement commits
10128/// topology and historical work as one desired-state transaction. A drained
10129/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
10130/// when the window held no logs), so the newly added filter gets the same
10131/// reconnect/catch-up protection an established one has.
10132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10133pub struct SubscriberBackfill {
10134    from_block: u64,
10135    to_block: Option<u64>,
10136    retained_anchor: Option<BlockRef>,
10137}
10138
10139impl SubscriberBackfill {
10140    /// Backfill an inclusive block range.
10141    pub fn range(from_block: u64, to_block: u64) -> Self {
10142        Self {
10143            from_block,
10144            to_block: Some(to_block),
10145            retained_anchor: None,
10146        }
10147    }
10148
10149    /// Backfill from `from_block` through the provider's latest block.
10150    pub fn from_block(from_block: u64) -> Self {
10151        Self {
10152            from_block,
10153            to_block: None,
10154            retained_anchor: None,
10155        }
10156    }
10157
10158    /// Backfill inclusively from an exact retained canonical block.
10159    ///
10160    /// The Alloy subscriber verifies this number/hash against its provider
10161    /// before accepting any lazy catch-up response. Engine-managed mid-stream
10162    /// registration uses this form so owner replay cannot silently cross a
10163    /// reorged discovery boundary.
10164    pub fn from_canonical_block(block: BlockRef) -> Self {
10165        Self {
10166            from_block: block.number,
10167            to_block: None,
10168            retained_anchor: Some(block),
10169        }
10170    }
10171
10172    /// Backfill inclusively from an exact canonical block through an inclusive
10173    /// upper bound.
10174    ///
10175    /// # Errors
10176    ///
10177    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10178    /// retained anchor.
10179    pub fn from_canonical_block_through(
10180        block: BlockRef,
10181        to_block: u64,
10182    ) -> Result<Self, SubscriberError> {
10183        if to_block < block.number {
10184            return Err(SubscriberError::InvalidConfig(
10185                "inclusive backfill upper bound precedes its retained anchor",
10186            ));
10187        }
10188        Ok(Self {
10189            from_block: block.number,
10190            to_block: Some(to_block),
10191            retained_anchor: Some(block),
10192        })
10193    }
10194
10195    /// Backfill strictly after an exact canonical state baseline.
10196    ///
10197    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
10198    /// a restored cache already embodies every effect through `block`, so
10199    /// replaying that block would apply it twice. The retained block is still
10200    /// carried so the subscriber can prove that its provider is on the same
10201    /// canonical branch before accepting any post-baseline history.
10202    ///
10203    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
10204    /// exclusive range into an inclusive replay of the baseline block.
10205    ///
10206    /// # Errors
10207    ///
10208    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
10209    /// `u64::MAX` and therefore has no following block.
10210    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
10211        Self::after_canonical_block_inner(block, None)
10212    }
10213
10214    /// Backfill strictly after an exact canonical baseline through an
10215    /// inclusive upper bound.
10216    ///
10217    /// `to_block == block.number` represents a deliberately empty certified
10218    /// interval. Bounds before the retained baseline are rejected.
10219    ///
10220    /// # Errors
10221    ///
10222    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10223    /// baseline, or when a non-empty exclusive range would have to begin after
10224    /// block `u64::MAX`.
10225    pub fn after_canonical_block_through(
10226        block: BlockRef,
10227        to_block: u64,
10228    ) -> Result<Self, SubscriberError> {
10229        if to_block < block.number {
10230            return Err(SubscriberError::InvalidConfig(
10231                "exclusive backfill upper bound precedes its retained baseline",
10232            ));
10233        }
10234        Self::after_canonical_block_inner(block, Some(to_block))
10235    }
10236
10237    fn after_canonical_block_inner(
10238        block: BlockRef,
10239        to_block: Option<u64>,
10240    ) -> Result<Self, SubscriberError> {
10241        let from_block = block
10242            .number
10243            .checked_add(1)
10244            .ok_or(SubscriberError::InvalidConfig(
10245                "cannot construct an exclusive backfill after block u64::MAX",
10246            ))?;
10247        Ok(Self {
10248            from_block,
10249            to_block,
10250            retained_anchor: Some(block),
10251        })
10252    }
10253
10254    /// First block included in the backfill.
10255    pub fn start_block(&self) -> u64 {
10256        self.from_block
10257    }
10258
10259    /// Last block included in the backfill, or `None` for provider latest.
10260    pub fn end_block(&self) -> Option<u64> {
10261        self.to_block
10262    }
10263
10264    /// Exact retained start-block identity, when supplied.
10265    pub fn retained_anchor(&self) -> Option<&BlockRef> {
10266        self.retained_anchor.as_ref()
10267    }
10268}
10269
10270/// Opaque generation for one transaction-aware subscriber interest owner.
10271///
10272/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
10273/// reused, including after an aborted stage or a full interest replacement.
10274/// Lifecycle operations require the complete token so a delayed command for an
10275/// older registration cannot affect a replacement using the same [`HandlerId`].
10276#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
10277pub struct SubscriberOwnerEpoch {
10278    owner: HandlerId,
10279    sequence: u64,
10280}
10281
10282/// Delivery audience retained with a subscriber input record.
10283///
10284/// Canonical inputs are forwarded once to the runtime actor and may also name
10285/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
10286/// overlap records that must never be routed through existing canonical
10287/// handlers.
10288#[derive(Clone, Debug, PartialEq, Eq)]
10289#[non_exhaustive]
10290pub enum SubscriberInputScope {
10291    /// One canonical input plus any staged owners that matched at enqueue time.
10292    Canonical {
10293        /// Staged owner epochs that require a buffered copy.
10294        owners: Vec<SubscriberOwnerEpoch>,
10295    },
10296    /// Canonical input whose owner catch-up already delivered selected handler
10297    /// owners. The residual canonical copy must exclude those handlers while
10298    /// remaining authoritative for global chain progress.
10299    CanonicalResidual {
10300        /// Staged epoch owners that still require a buffered copy.
10301        owners: Vec<SubscriberOwnerEpoch>,
10302        /// Active compatibility owners already served by owner catch-up.
10303        excluded: Vec<HandlerId>,
10304    },
10305    /// Input delivered only to the listed staged owners.
10306    OwnerOnly {
10307        /// Exact staged owner epochs receiving the input.
10308        owners: Vec<SubscriberOwnerEpoch>,
10309    },
10310    /// Compatibility owner-only delivery keyed by stable handler id.
10311    OwnerOnlyHandlers {
10312        /// Exact active handlers receiving the catch-up input.
10313        owners: Vec<HandlerId>,
10314    },
10315    /// Flashblock input routed through ordinary matching handlers but applied
10316    /// only to the speculative overlay.
10317    Preconfirmed,
10318}
10319
10320impl SubscriberInputScope {
10321    /// Exact staged owner epochs attached to this input.
10322    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
10323        match self {
10324            Self::Canonical { owners }
10325            | Self::CanonicalResidual { owners, .. }
10326            | Self::OwnerOnly { owners } => owners,
10327            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
10328        }
10329    }
10330
10331    /// Whether this input must be forwarded once through canonical routing.
10332    pub const fn is_canonical(&self) -> bool {
10333        matches!(
10334            self,
10335            Self::Canonical { .. } | Self::CanonicalResidual { .. }
10336        )
10337    }
10338
10339    /// Whether this input belongs only to the disposable preconfirmed overlay.
10340    pub const fn is_preconfirmed(&self) -> bool {
10341        matches!(self, Self::Preconfirmed)
10342    }
10343}
10344
10345/// Reactive input together with its canonical/owner-scoped delivery audience.
10346#[derive(Clone, Debug)]
10347pub struct SubscriberInputRecord<N: Network = Ethereum> {
10348    record: ReactiveInputRecord<N>,
10349    scope: SubscriberInputScope,
10350}
10351
10352impl<N: Network> SubscriberInputRecord<N> {
10353    /// Borrow the reactive input record.
10354    pub const fn record(&self) -> &ReactiveInputRecord<N> {
10355        &self.record
10356    }
10357
10358    /// Delivery audience captured when the record was enqueued.
10359    pub const fn scope(&self) -> &SubscriberInputScope {
10360        &self.scope
10361    }
10362
10363    /// Consume the scoped value into its reactive input record.
10364    pub fn into_record(self) -> ReactiveInputRecord<N> {
10365        self.record
10366    }
10367}
10368
10369impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
10370    type Target = ReactiveInputRecord<N>;
10371
10372    fn deref(&self) -> &Self::Target {
10373        &self.record
10374    }
10375}
10376
10377/// Batch of subscriber inputs with enqueue-time owner provenance.
10378#[derive(Clone, Debug)]
10379pub struct SubscriberInputBatch<N: Network = Ethereum> {
10380    records: Vec<SubscriberInputRecord<N>>,
10381    chain_id: Option<u64>,
10382    chain_controls: Vec<ChainControl>,
10383    preconfirmation_invalidated: bool,
10384}
10385
10386/// Result of polling a scoped subscriber batch against one driver control
10387/// future.
10388#[derive(Debug)]
10389#[non_exhaustive]
10390pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
10391    /// The control future completed first; subscriber delivery remains intact.
10392    Control(C),
10393    /// Subscriber polling completed first.
10394    Batch(Option<SubscriberInputBatch<N>>),
10395}
10396
10397impl<N: Network> SubscriberInputBatch<N> {
10398    /// Borrow every scoped record in delivery order.
10399    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
10400        &self.records
10401    }
10402
10403    /// Consume the batch into its scoped records.
10404    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
10405        self.records
10406    }
10407
10408    /// Ordered chain controls committed after the preceding records.
10409    pub fn chain_controls(&self) -> &[ChainControl] {
10410        &self.chain_controls
10411    }
10412
10413    /// Whether the announcing Flashblocks generation lost continuity before
10414    /// this batch was returned.
10415    pub const fn preconfirmation_invalidated(&self) -> bool {
10416        self.preconfirmation_invalidated
10417    }
10418
10419    /// Consume the scoped subscriber delivery into a runtime-ready batch.
10420    ///
10421    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
10422    /// allowing downstream owner actors to forward a batch without rebuilding
10423    /// subscriber-internal scope metadata.
10424    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
10425        let chain_id = self.chain_id;
10426        let chain_controls = self.chain_controls;
10427        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
10428            self.records.into_iter().map(|scoped| {
10429                let source = scoped.record.context.source;
10430                let (audience, delivery_scope) = match scoped.scope {
10431                    SubscriberInputScope::Canonical { .. } => (
10432                        DeliveryAudience::All,
10433                        if source == InputSource::Backfill {
10434                            DeliveryScope::CanonicalProgress
10435                        } else {
10436                            DeliveryScope::Canonical
10437                        },
10438                    ),
10439                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
10440                        DeliveryAudience::AllExcept(excluded),
10441                        if source == InputSource::Backfill {
10442                            DeliveryScope::CanonicalProgress
10443                        } else {
10444                            DeliveryScope::Canonical
10445                        },
10446                    ),
10447                    SubscriberInputScope::OwnerOnly { owners } => {
10448                        let mut handler_ids = Vec::with_capacity(owners.len());
10449                        for epoch in owners {
10450                            if !handler_ids.contains(epoch.owner()) {
10451                                handler_ids.push(epoch.owner().clone());
10452                            }
10453                        }
10454                        (
10455                            DeliveryAudience::Owners(handler_ids),
10456                            DeliveryScope::OwnerCatchup,
10457                        )
10458                    }
10459                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
10460                        DeliveryAudience::Owners(owners),
10461                        DeliveryScope::OwnerCatchup,
10462                    ),
10463                    SubscriberInputScope::Preconfirmed => {
10464                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
10465                    }
10466                };
10467                (scoped.record, audience, delivery_scope)
10468            }),
10469        )
10470        .with_chain_controls(chain_controls);
10471        if let Some(chain_id) = chain_id {
10472            batch = batch.with_chain_id(chain_id);
10473        }
10474        batch
10475    }
10476}
10477
10478impl SubscriberOwnerEpoch {
10479    /// Logical subscriber owner represented by this epoch.
10480    pub const fn owner(&self) -> &HandlerId {
10481        &self.owner
10482    }
10483
10484    /// Monotonic subscriber-local epoch sequence.
10485    pub const fn sequence(&self) -> u64 {
10486        self.sequence
10487    }
10488}
10489
10490/// Catch-up policy applied when staging a transaction-aware interest owner.
10491#[derive(Clone, Debug, PartialEq, Eq)]
10492#[non_exhaustive]
10493pub enum SubscriberOwnerStart {
10494    /// Start with live delivery only.
10495    Live,
10496    /// Start strictly after an already-applied post-block baseline.
10497    ///
10498    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
10499    /// itself is never replayed. Transaction-aware callers explicitly call
10500    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
10501    /// owners never use the legacy lazy-backfill queue.
10502    PostBlock(BlockRef),
10503}
10504
10505/// Transaction state of one epoch-scoped subscriber owner.
10506#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10507#[non_exhaustive]
10508pub enum SubscriberOwnerState {
10509    /// Desired interests and owner-scoped buffering are installed but canonical
10510    /// routing has not yet committed.
10511    Staged,
10512    /// Canonical runtime routing has committed for this owner.
10513    Active,
10514    /// Removal is prepared behind a delivery fence but remains reversible.
10515    Removing,
10516}
10517
10518/// Hash-certified catch-up position reached by one subscriber owner epoch.
10519///
10520/// Progress means every owner-only record through this point has been fetched
10521/// and queued inside the subscriber. It does not mean the downstream actor has
10522/// drained or committed those records; that requires a separate delivery fence.
10523#[derive(Clone, Debug, PartialEq, Eq)]
10524pub struct SubscriberOwnerProgress {
10525    owner: SubscriberOwnerEpoch,
10526    through: BlockRef,
10527}
10528
10529impl SubscriberOwnerProgress {
10530    /// Exact owner epoch whose catch-up was reconciled.
10531    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
10532        &self.owner
10533    }
10534
10535    /// Verified canonical block through which owner input was fetched.
10536    pub const fn through(&self) -> &BlockRef {
10537        &self.through
10538    }
10539}
10540
10541/// Error staging a transaction-aware subscriber owner.
10542#[derive(Debug, thiserror::Error)]
10543#[non_exhaustive]
10544pub enum SubscriberOwnerError {
10545    /// Subscriber configuration or interest validation failed.
10546    #[error(transparent)]
10547    Subscriber(#[from] SubscriberError),
10548    /// The logical owner already has desired interests installed.
10549    #[error("subscriber interest owner `{0}` is already registered")]
10550    AlreadyRegistered(HandlerId),
10551    /// A post-block baseline cannot be advanced to its first unapplied block.
10552    #[error("post-block subscriber baseline {0} has no following block")]
10553    PostBlockOverflow(u64),
10554    /// The monotonic subscriber owner epoch sequence was exhausted.
10555    #[error("subscriber owner epoch sequence exhausted")]
10556    EpochExhausted,
10557    /// The exact owner epoch is unknown or no longer staged.
10558    #[error("subscriber owner epoch is not staged")]
10559    NotStaged,
10560    /// Live-only staging has no historical baseline to reconcile.
10561    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
10562    MissingBaseline,
10563    /// Post-block reconciliation currently covers log interests only.
10564    #[error("post-block subscriber owners support log interests only")]
10565    UnsupportedPostBlockInterest,
10566    /// The target block was absent from the provider.
10567    #[error("subscriber reconcile target block {0} was not found")]
10568    BlockUnavailable(u64),
10569    /// The provider's canonical identity did not match the requested target.
10570    #[error(
10571        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
10572    )]
10573    BlockMismatch {
10574        /// Requested block number.
10575        expected_number: u64,
10576        /// Requested block hash.
10577        expected_hash: B256,
10578        /// Provider block number.
10579        actual_number: u64,
10580        /// Provider block hash.
10581        actual_hash: B256,
10582    },
10583    /// A reconcile target was older than the retained baseline/progress.
10584    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
10585    ProgressRegression {
10586        /// Retained baseline or progress block.
10587        current: u64,
10588        /// Rejected target block.
10589        target: u64,
10590    },
10591    /// A reconcile attempted to replace a retained block identity at the same
10592    /// height or cross an immediate parent that does not extend it.
10593    #[error(
10594        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
10595    )]
10596    ProgressConflict {
10597        /// Retained baseline or progress block number.
10598        number: u64,
10599        /// Retained baseline or progress block hash.
10600        current_hash: B256,
10601        /// Conflicting target hash or immediate parent hash.
10602        target_hash: B256,
10603    },
10604    /// A provider returned a malformed or out-of-range catch-up log.
10605    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
10606    InvalidBackfillLog(&'static str),
10607}
10608
10609/// Extension trait for subscribers that can add and remove handler-owned
10610/// interests incrementally.
10611///
10612/// [`EventSubscriber::register_interests`] remains the full-replacement setup
10613/// API. Implement this trait when a subscriber can preserve unrelated live
10614/// sources and delivery state while one handler's interests are added or
10615/// removed. Implementations should make owner *replacement* continuity-safe:
10616/// updating an owner's interests must not silently discard delivery progress
10617/// the previous interests had already established (the in-crate
10618/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
10619/// changed filter shapes and automatically backfills the gap). Every mutating
10620/// operation is also a commit boundary: returning `Ok` means the new desired
10621/// state is authoritative, while errors or cancellation must preserve the
10622/// previous state or reconcile before exposing the uncommitted change.
10623pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
10624    /// Atomically add or replace several owners in one desired-state revision.
10625    ///
10626    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
10627    /// boundary for the complete set; an error or cancellation must leave the
10628    /// previously committed owner topology authoritative. Durable remote
10629    /// subscribers should override this method so bootstrap creates one service
10630    /// revision and one activation barrier rather than one barrier per owner.
10631    ///
10632    /// # Errors
10633    ///
10634    /// The returned operation reports [`SubscriberError::Unsupported`] by
10635    /// default, or an implementation-specific validation or commit failure.
10636    fn upsert_interest_owners(
10637        &mut self,
10638        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10639    ) -> SubscriberOperation<'_, ()> {
10640        Box::pin(async {
10641            Err(SubscriberError::Unsupported(
10642                "subscriber does not implement atomic bulk owner upsert",
10643            ))
10644        })
10645    }
10646
10647    /// Atomically replace the complete engine-managed owner topology without
10648    /// requesting history.
10649    ///
10650    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
10651    /// stale owners, queued delivery, and dedupe/source state from the prior
10652    /// topology must not survive a successful replacement. Errors and dropped
10653    /// futures leave the prior committed topology authoritative.
10654    ///
10655    /// # Errors
10656    ///
10657    /// The returned operation reports [`SubscriberError::Unsupported`] by
10658    /// default, or an implementation-specific validation or commit failure.
10659    fn replace_interest_owners(
10660        &mut self,
10661        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10662    ) -> SubscriberOperation<'_, ()> {
10663        Box::pin(async {
10664            Err(SubscriberError::Unsupported(
10665                "subscriber does not implement atomic exact owner replacement",
10666            ))
10667        })
10668    }
10669
10670    /// Atomically replace the complete owner set and schedule one global
10671    /// historical log backfill in the same desired-state revision.
10672    ///
10673    /// This is the continuity-safe bootstrap operation for a runtime that has
10674    /// already processed canonical state while the subscriber's owner state is
10675    /// new or may have been lost. Implementations must commit the complete
10676    /// owner topology and all required historical work together: returning an
10677    /// error or dropping the future must leave the previously committed state
10678    /// authoritative. The default is deliberately unsupported rather than a
10679    /// sequence of partially committed single-owner updates.
10680    /// Historical records must be delivered through canonical global routing
10681    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
10682    /// owner catch-up, so their effects participate in the normal rollback
10683    /// journal before the source certifies the cutover. Base/unowned interests
10684    /// are replaced by this complete engine-managed topology. Any owner absent
10685    /// from `owners` must be removed together with its queued owner-only work, which closes
10686    /// the crash window where a subscriber committed registration but the
10687    /// runtime process died before installing the corresponding handler.
10688    ///
10689    /// # Errors
10690    ///
10691    /// The returned operation reports [`SubscriberError::Unsupported`] by
10692    /// default, or a backfill, validation, transport, or atomic-commit failure.
10693    fn replace_interest_owners_with_global_backfill(
10694        &mut self,
10695        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10696        _backfill: SubscriberBackfill,
10697    ) -> SubscriberOperation<'_, ()> {
10698        Box::pin(async {
10699            Err(SubscriberError::Unsupported(
10700                "subscriber does not implement atomic owner replacement with global backfill",
10701            ))
10702        })
10703    }
10704
10705    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
10706    /// commit boundary.
10707    ///
10708    /// Implementations must leave the previously committed owner state
10709    /// authoritative when the operation returns an error or is cancelled before
10710    /// completion.
10711    ///
10712    /// # Errors
10713    ///
10714    /// The returned operation reports [`SubscriberError`] when the owner update
10715    /// cannot be validated or committed.
10716    fn add_interest_owner(
10717        &mut self,
10718        owner: HandlerId,
10719        interests: &[ReactiveInterest<N>],
10720    ) -> SubscriberOperation<'_, ()>;
10721
10722    /// Add or replace owner interests and schedule log backfill for that owner,
10723    /// awaiting the subscriber's commit boundary.
10724    ///
10725    /// # Errors
10726    ///
10727    /// The returned operation reports [`SubscriberError`] when the owner update
10728    /// or requested backfill cannot be validated or committed.
10729    fn add_interest_owner_with_backfill(
10730        &mut self,
10731        owner: HandlerId,
10732        interests: &[ReactiveInterest<N>],
10733        backfill: SubscriberBackfill,
10734    ) -> SubscriberOperation<'_, ()>;
10735
10736    /// Add a handler discovered at retained canonical block `C` without
10737    /// opening a gap while registration commits.
10738    ///
10739    /// The subscriber must subscribe/adopt the new desired state first, then
10740    /// expose the new owner's matching records from `C` as owner catch-up and
10741    /// expose `C + 1` through the activation head as one globally ordered
10742    /// canonical catch-up over the complete active interest union. This split
10743    /// is deliberate: the runtime already has a rollback entry for `C`, while
10744    /// later blocks must run every handler and create normal canonical journal
10745    /// entries. Errors/cancellation preserve the prior committed topology.
10746    /// Implementations that cannot uphold this coordinated transaction must
10747    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
10748    ///
10749    /// # Errors
10750    ///
10751    /// The returned operation reports [`SubscriberError::Unsupported`] by
10752    /// default, or a canonical-anchor, transport, or atomic-commit failure.
10753    fn add_interest_owner_with_canonical_catchup(
10754        &mut self,
10755        _owner: HandlerId,
10756        _interests: &[ReactiveInterest<N>],
10757        _retained: BlockRef,
10758    ) -> SubscriberOperation<'_, ()> {
10759        Box::pin(async {
10760            Err(SubscriberError::Unsupported(
10761                "subscriber does not implement coordinated canonical owner catch-up",
10762            ))
10763        })
10764    }
10765
10766    /// Remove one owner's interests, preserving unrelated interests, and await
10767    /// acknowledgement that the removal committed.
10768    ///
10769    /// On error the owner must remain authoritative, so the runtime handler is
10770    /// not removed while subscriber delivery may still target it.
10771    ///
10772    /// # Errors
10773    ///
10774    /// The returned operation reports [`SubscriberError`] when the removal
10775    /// cannot be committed while preserving unrelated owners.
10776    fn remove_interest_owner(
10777        &mut self,
10778        owner: &HandlerId,
10779    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
10780
10781    /// Borrow the interests currently owned by `owner`.
10782    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
10783}
10784
10785/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
10786/// subscribe-ingest lifecycle.
10787///
10788/// The engine treats the runtime registry as the single source of truth for
10789/// handler lifecycle: [`register_handler`](Self::register_handler) and
10790/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
10791/// subscriber interests as one operation, keyed by the handler's stable
10792/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
10793/// has journaled canonical block *N*, a newly registered handler is live-adopted,
10794/// replayed owner-only at *N*, and then caught up globally with every handler
10795/// from *N + 1* through activation. A factory-discovered pool therefore misses
10796/// none of its own logs without making later history owner-local and
10797/// unrollbackable. The subscriber must absorb overlap that crosses batch
10798/// boundaries; the runtime validates and merges duplicate representations only
10799/// within one [`ReactiveInputBatch`].
10800///
10801/// Registration methods by intent:
10802///
10803/// | Method | Backfill |
10804/// |---|---|
10805/// | [`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) |
10806/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
10807/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
10808///
10809/// Unregistering a handler stops future subscription routing and runtime
10810/// decode for that handler; it deliberately does not evict [`EvmCache`] state
10811/// or undo runtime side effects. See
10812/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
10813/// recipe.
10814///
10815/// The runtime and subscriber stay independently accessible through
10816/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
10817/// for advanced use. One caution: avoid calling
10818/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
10819/// an engine-managed subscriber — implementations may clear owner-scoped
10820/// bookkeeping, after which per-handler unregistration no longer releases the
10821/// handler's transport subscriptions. To bootstrap the subscriber from a
10822/// runtime that already has handlers, use
10823/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
10824/// one owner per handler instead of one unowned blob.
10825pub struct ReactiveEngine<S, N: Network = Ethereum> {
10826    runtime: ReactiveRuntime<N>,
10827    subscriber: S,
10828    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
10829    pending_checkpoint: Option<PendingCheckpoint<N>>,
10830    last_checkpoint_block: Option<DurableCheckpointBlock>,
10831    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
10832    last_checkpoint_delivery_witness: Option<B256>,
10833    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
10834    checkpoint_identity: Option<DurableCheckpointIdentity>,
10835}
10836
10837struct PendingAcknowledgement<N: Network> {
10838    token: SubscriberDeliveryToken,
10839    report: ReactiveBatchReport<N>,
10840}
10841
10842struct PendingCheckpoint<N: Network> {
10843    metadata: DurableCheckpointMetadata,
10844    delivery_token: Option<SubscriberDeliveryToken>,
10845    report: ReactiveBatchReport<N>,
10846    saved_to: Option<PathBuf>,
10847    staged_generation: u64,
10848}
10849
10850struct CheckpointStage<N: Network> {
10851    incoming_block: Option<DurableCheckpointBlock>,
10852    delivery_token: Option<SubscriberDeliveryToken>,
10853    delivery_witness: Option<B256>,
10854    subscriber_checkpoint: Option<SubscriberCheckpoint>,
10855    staged_generation: u64,
10856    report: ReactiveBatchReport<N>,
10857}
10858
10859struct DurableResumePlan {
10860    runtime: DurableRuntimeRestorePlan,
10861    position: SubscriberResumePosition,
10862    delivery_witness: Option<B256>,
10863}
10864
10865enum HandlerRegistrationCatchup {
10866    LiveOnly,
10867    OwnerBackfill(SubscriberBackfill),
10868    CoordinatedCanonical(BlockRef),
10869}
10870
10871const DELIVERY_WITNESS_VERSION: u32 = 1;
10872const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
10873
10874#[derive(serde::Serialize)]
10875struct DeliveryWitnessEnvelope<'a> {
10876    version: u32,
10877    chain_id: Option<u64>,
10878    records: Vec<DeliveryRecordWitness<'a>>,
10879    chain_controls: &'a [ChainControl],
10880    subscriber_checkpoint: Option<&'a [u8]>,
10881    payload_commitment: Option<B256>,
10882}
10883
10884#[derive(serde::Serialize)]
10885struct DeliveryRecordWitness<'a> {
10886    identity: ReactiveInputIdentity,
10887    context: &'a ReactiveContext,
10888    audience: &'a DeliveryAudience,
10889    scope: DeliveryScope,
10890    payload: DeliveryPayloadWitness<'a>,
10891}
10892
10893#[derive(serde::Serialize)]
10894enum DeliveryPayloadWitness<'a> {
10895    /// Logs are the primary state-bearing event representation, so retain every
10896    /// RPC payload field in addition to the validated identity/context.
10897    Log {
10898        address: Address,
10899        topics: &'a [B256],
10900        data: &'a Bytes,
10901        block_hash: Option<B256>,
10902        block_number: Option<u64>,
10903        block_timestamp: Option<u64>,
10904        transaction_hash: Option<B256>,
10905        transaction_index: Option<u64>,
10906        log_index: Option<u64>,
10907        removed: bool,
10908    },
10909    /// Network-generic response bodies do not expose one stable complete serde
10910    /// contract. Their validated identity/context are witnessed here; batches
10911    /// containing headers, full blocks, or hydrated transactions additionally
10912    /// require the source's exact canonical wire-payload commitment. A generic
10913    /// header response can expose a supplied hash without proving that every
10914    /// handler-visible inner field recomputes to it.
10915    IdentityCommitted,
10916}
10917
10918fn durable_delivery_witness<N: Network>(
10919    batch: &ReactiveInputBatch<N>,
10920) -> Result<B256, ReactiveEngineError> {
10921    let requires_payload_commitment = batch.records.iter().any(|record| {
10922        matches!(
10923            &record.input,
10924            ReactiveInput::BlockHeader(_)
10925                | ReactiveInput::FullBlock(_)
10926                | ReactiveInput::PendingTx(_)
10927        )
10928    });
10929    if requires_payload_commitment && batch.payload_commitment.is_none() {
10930        return Err(ReactiveEngineError::MissingPayloadCommitment);
10931    }
10932    let records = batch
10933        .records
10934        .iter()
10935        .enumerate()
10936        .map(|(index, record)| {
10937            let payload = match &record.input {
10938                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
10939                    address: log.address(),
10940                    topics: log.topics(),
10941                    data: &log.inner.data.data,
10942                    block_hash: log.block_hash,
10943                    block_number: log.block_number,
10944                    block_timestamp: log.block_timestamp,
10945                    transaction_hash: log.transaction_hash,
10946                    transaction_index: log.transaction_index,
10947                    log_index: log.log_index,
10948                    removed: log.removed,
10949                },
10950                ReactiveInput::BlockHeader(_)
10951                | ReactiveInput::FullBlock(_)
10952                | ReactiveInput::PendingTxHash(_)
10953                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
10954            };
10955            Ok(DeliveryRecordWitness {
10956                identity: record.validated_identity()?,
10957                context: &record.context,
10958                audience: batch
10959                    .record_audience(index)
10960                    .expect("enumerated record always has an audience"),
10961                scope: batch
10962                    .record_delivery_scope(index)
10963                    .expect("enumerated record always has a delivery scope"),
10964                payload,
10965            })
10966        })
10967        .collect::<Result<Vec<_>, ReactiveError>>()?;
10968    let envelope = DeliveryWitnessEnvelope {
10969        version: DELIVERY_WITNESS_VERSION,
10970        chain_id: batch.chain_id,
10971        records,
10972        chain_controls: &batch.chain_controls,
10973        subscriber_checkpoint: batch
10974            .subscriber_checkpoint
10975            .as_ref()
10976            .map(SubscriberCheckpoint::as_bytes),
10977        payload_commitment: batch
10978            .payload_commitment
10979            .as_ref()
10980            .map(SubscriberPayloadCommitment::digest),
10981    };
10982    let encoded = bincode::DefaultOptions::new()
10983        .with_fixint_encoding()
10984        .serialize(&envelope)
10985        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
10986    let mut witness = Keccak256::new();
10987    witness.update(DELIVERY_WITNESS_DOMAIN);
10988    witness.update(encoded);
10989    Ok(witness.finalize())
10990}
10991
10992impl<S, N> ReactiveEngine<S, N>
10993where
10994    N: Network,
10995    S: EventSubscriber<N>,
10996{
10997    /// Bind a runtime and subscriber.
10998    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
10999        Self {
11000            runtime,
11001            subscriber,
11002            pending_acknowledgement: None,
11003            pending_checkpoint: None,
11004            last_checkpoint_block: None,
11005            last_checkpoint_delivery_token: None,
11006            last_checkpoint_delivery_witness: None,
11007            last_subscriber_checkpoint: None,
11008            checkpoint_identity: None,
11009        }
11010    }
11011
11012    /// Split the engine into its runtime and subscriber parts when no commit is
11013    /// pending.
11014    ///
11015    /// A failed delivery acknowledgement or durable checkpoint commit remains
11016    /// live protocol state: dropping it would allow the caller to lose the
11017    /// already-applied report/token pair and poll past an uncommitted batch.
11018    /// In that case this returns the intact engine so the caller can repair the
11019    /// dependency and retry through the normal ingestion method.
11020    ///
11021    /// # Errors
11022    ///
11023    /// Returns the intact boxed engine when an acknowledgement or checkpoint
11024    /// commit is pending.
11025    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
11026        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
11027            return Err(Box::new(self));
11028        }
11029        Ok((self.runtime, self.subscriber))
11030    }
11031
11032    fn durable_resume_plan(
11033        &self,
11034        metadata: &DurableCheckpointMetadata,
11035    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
11036        if !self.subscriber.capabilities().supports_durable_replay() {
11037            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11038        }
11039        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
11040        if !self.runtime.is_pristine_for_checkpoint_restore()
11041            || self.pending_acknowledgement.is_some()
11042            || self.pending_checkpoint.is_some()
11043            || self.last_checkpoint_block.is_some()
11044            || self.last_checkpoint_delivery_token.is_some()
11045            || self.last_checkpoint_delivery_witness.is_some()
11046            || self.last_subscriber_checkpoint.is_some()
11047            || self.checkpoint_identity.is_some()
11048        {
11049            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11050        }
11051
11052        let block = BlockRef {
11053            number: metadata.block.number,
11054            hash: metadata.block.hash,
11055            parent_hash: metadata.block.parent_hash,
11056            timestamp: metadata.block.timestamp,
11057        };
11058        let runtime = match metadata.runtime_checkpoint.as_deref() {
11059            Some(bytes) => self
11060                .runtime
11061                .plan_durable_checkpoint_restore(bytes, &block)?,
11062            None => DurableRuntimeRestorePlan {
11063                checkpoint: None,
11064                fallback_history: (self.runtime.config.journal_depth > 0)
11065                    .then_some(block)
11066                    .into_iter()
11067                    .collect(),
11068            },
11069        };
11070        let delivery_token = metadata
11071            .delivery_token
11072            .clone()
11073            .map(SubscriberDeliveryToken::new);
11074        let subscriber_checkpoint = metadata
11075            .subscriber_checkpoint
11076            .clone()
11077            .map(SubscriberCheckpoint::new);
11078        let position = SubscriberResumePosition::new(
11079            metadata.identity.chain_id,
11080            block,
11081            runtime.canonical_history(),
11082            delivery_token,
11083            subscriber_checkpoint,
11084        );
11085        Ok(DurableResumePlan {
11086            runtime,
11087            position,
11088            delivery_witness: metadata.delivery_witness,
11089        })
11090    }
11091
11092    /// Preview the exact subscriber position a durable restore will install.
11093    ///
11094    /// This read-only step exists for durable subscribers that must complete
11095    /// asynchronous source or transport preparation before the engine invokes
11096    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
11097    /// and validates the core runtime checkpoint, applies this runtime's
11098    /// configured journal retention to the preview, and returns the same
11099    /// [`SubscriberResumePosition`] that
11100    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11101    /// will later pass to the subscriber.
11102    ///
11103    /// Call this on the same fresh engine that will perform the restore. After
11104    /// subscriber preparation completes, pass the identical `metadata` to
11105    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
11106    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
11107    /// without mutating engine runtime or checkpoint state in between. The
11108    /// checkpoint identity and, for non-finalized state, its canonical block
11109    /// must still be validated by the caller before external preparation.
11110    ///
11111    /// This method does not mutate the runtime, subscriber, or checkpoint
11112    /// bookkeeping.
11113    ///
11114    /// # Errors
11115    ///
11116    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11117    /// durable, its chain identity conflicts with the checkpoint, the engine is
11118    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
11119    /// or internally inconsistent.
11120    pub fn preview_durable_resume_position(
11121        &self,
11122        metadata: &DurableCheckpointMetadata,
11123    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
11124        Ok(self.durable_resume_plan(metadata)?.position)
11125    }
11126
11127    /// Resume delivery bookkeeping and canonical continuity from a cache
11128    /// checkpoint that has already been identity- and hash-validated and
11129    /// restored into [`EvmCache`].
11130    ///
11131    /// Call this on a fresh engine. The anchor has no rollback effects of its
11132    /// own: it represents the state baseline embodied by the checkpoint, while
11133    /// newly ingested blocks are journaled normally above it.
11134    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
11135    /// restoring an ephemeral stream would claim a restart guarantee it cannot
11136    /// uphold and is rejected before cache or runtime mutation.
11137    ///
11138    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
11139    /// when the cache has not yet been restored: that helper rolls the cache
11140    /// back as well if runtime or subscriber activation fails.
11141    ///
11142    /// # Errors
11143    ///
11144    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11145    /// durable, chain identity conflicts, the runtime is not pristine, stored
11146    /// runtime state is invalid, or the subscriber rejects the restored
11147    /// position. Runtime state is restored on subscriber failure.
11148    pub fn resume_from_durable_checkpoint(
11149        &mut self,
11150        metadata: &DurableCheckpointMetadata,
11151    ) -> Result<(), ReactiveCheckpointRestoreError> {
11152        let plan = self.durable_resume_plan(metadata)?;
11153        let prior_runtime = self.runtime.checkpoint_state();
11154
11155        let DurableResumePlan {
11156            runtime,
11157            position,
11158            delivery_witness,
11159        } = plan;
11160        self.runtime.apply_durable_checkpoint_restore(runtime);
11161        self.runtime.coverage_head = Some(position.coverage_head);
11162        if let Err(error) = self.subscriber.restore_position(&position) {
11163            self.runtime.restore_state(prior_runtime);
11164            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
11165        }
11166        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
11167            self.runtime.restore_state(prior_runtime);
11168            return Err(error);
11169        }
11170        self.last_checkpoint_block = Some(metadata.block.clone());
11171        self.last_checkpoint_delivery_token = position.delivery_token;
11172        self.last_checkpoint_delivery_witness = delivery_witness;
11173        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
11174        self.checkpoint_identity = Some(metadata.identity.clone());
11175        Ok(())
11176    }
11177
11178    /// Atomically restore cache, runtime, and subscriber position from one
11179    /// validated durable checkpoint.
11180    ///
11181    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
11182    /// block against an authoritative RPC source before calling this method when
11183    /// the block is not finalized. Identity, cache-chain, runtime-state, and
11184    /// subscriber failures leave the cache and engine runtime unchanged. The
11185    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
11186    /// It must advertise [`SubscriberCapability::DurableReplay`].
11187    ///
11188    /// # Errors
11189    ///
11190    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
11191    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
11192    /// position-restore failures. Cache and runtime state remain unchanged.
11193    pub fn restore_durable_checkpoint(
11194        &mut self,
11195        cache: &mut EvmCache,
11196        loaded: LoadedDurableCheckpoint,
11197        expected: &DurableCheckpointIdentity,
11198    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
11199        if !self.subscriber.capabilities().supports_durable_replay() {
11200            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11201        }
11202        self.ensure_subscriber_restore_chain(expected.chain_id)?;
11203        if !self.runtime.is_pristine_for_checkpoint_restore()
11204            || self.pending_acknowledgement.is_some()
11205            || self.pending_checkpoint.is_some()
11206            || self.last_checkpoint_block.is_some()
11207            || self.last_checkpoint_delivery_token.is_some()
11208            || self.last_checkpoint_delivery_witness.is_some()
11209            || self.last_subscriber_checkpoint.is_some()
11210            || self.checkpoint_identity.is_some()
11211        {
11212            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11213        }
11214
11215        let prior_cache = EvmCacheStateSnapshot::capture(cache);
11216        let metadata = loaded.restore_into(cache, expected)?;
11217        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
11218            prior_cache.restore(cache);
11219            return Err(error);
11220        }
11221        Ok(metadata)
11222    }
11223
11224    /// Borrow the runtime.
11225    pub fn runtime(&self) -> &ReactiveRuntime<N> {
11226        &self.runtime
11227    }
11228
11229    /// Mutably borrow the runtime.
11230    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
11231        &mut self.runtime
11232    }
11233
11234    /// Borrow the subscriber.
11235    pub fn subscriber(&self) -> &S {
11236        &self.subscriber
11237    }
11238
11239    /// Mutably borrow the subscriber.
11240    pub fn subscriber_mut(&mut self) -> &mut S {
11241        &mut self.subscriber
11242    }
11243
11244    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
11245    /// cold-start baseline.
11246    ///
11247    /// The cache must use the exact canonical hash selector and block-number
11248    /// context named by `baseline`; when the baseline includes a timestamp, the
11249    /// cache timestamp must match too. Cache, baseline, and any already-resolved
11250    /// subscriber identity must name the same chain. No delivery or checkpoint
11251    /// commit may be pending. After this succeeds, call
11252    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
11253    /// before polling: it exact-replaces subscriber owners and begins event
11254    /// catch-up at `C + 1`.
11255    ///
11256    /// # Errors
11257    ///
11258    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
11259    /// is active or already has a conflicting baseline, cache/subscriber chain
11260    /// identity differs, or the cache is not pinned to the exact baseline.
11261    pub fn adopt_canonical_baseline(
11262        &mut self,
11263        cache: &EvmCache,
11264        baseline: ReactiveCanonicalBaseline,
11265    ) -> Result<(), ReactiveEngineError> {
11266        if self.pending_acknowledgement.is_some()
11267            || self.pending_checkpoint.is_some()
11268            || self.last_checkpoint_block.is_some()
11269            || self.last_checkpoint_delivery_token.is_some()
11270            || self.last_checkpoint_delivery_witness.is_some()
11271            || self.last_subscriber_checkpoint.is_some()
11272            || self.checkpoint_identity.is_some()
11273        {
11274            return Err(ReactiveBaselineError::ActiveRuntime.into());
11275        }
11276        // Establish deterministic lifecycle/idempotency semantics before
11277        // consulting mutable cache context. A conflicting repeat is a runtime
11278        // baseline conflict even if the caller also repointed the cache.
11279        self.runtime
11280            .validate_canonical_baseline_adoption(baseline.block)?;
11281        if baseline.chain_id != cache.chain_id() {
11282            return Err(ReactiveBaselineError::CacheChainMismatch {
11283                baseline_chain_id: baseline.chain_id,
11284                cache_chain_id: cache.chain_id(),
11285            }
11286            .into());
11287        }
11288        self.ensure_subscriber_chain(cache)?;
11289        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
11290        let context_matches = cache.block_number() == Some(baseline.block.number)
11291            && baseline
11292                .block
11293                .timestamp
11294                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
11295        if cache.block() != exact_selector || !context_matches {
11296            return Err(ReactiveBaselineError::CacheBlockMismatch {
11297                number: baseline.block.number,
11298                hash: baseline.block.hash,
11299            }
11300            .into());
11301        }
11302        self.runtime.adopt_canonical_baseline(baseline.block)?;
11303        Ok(())
11304    }
11305
11306    /// Poll the subscriber for the next batch without ingesting it.
11307    ///
11308    /// This low-level escape hatch is unavailable while the engine owes an
11309    /// acknowledgement or checkpoint commit. Callers that use it must return
11310    /// any subscriber-owned delivery metadata through a combined
11311    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
11312    /// rejects that metadata so it cannot be discarded accidentally.
11313    ///
11314    /// # Errors
11315    ///
11316    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
11317    /// is pending or subscriber and cache chain identities conflict.
11318    pub fn next_batch(
11319        &mut self,
11320        cache: &EvmCache,
11321    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
11322        if self.pending_checkpoint.is_some() {
11323            return Err(ReactiveEngineError::PendingCheckpointCommit);
11324        }
11325        if self.pending_acknowledgement.is_some() {
11326            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11327        }
11328        self.ensure_subscriber_chain(cache)?;
11329        Ok(self.subscriber.next_batch())
11330    }
11331
11332    /// Ingest one already-polled batch through the runtime (direct effects
11333    /// only; surfaced resync requests are reported, not executed).
11334    ///
11335    /// # Errors
11336    ///
11337    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11338    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11339    /// runtime ingestion fails.
11340    pub fn ingest_batch(
11341        &mut self,
11342        cache: &mut EvmCache,
11343        batch: ReactiveInputBatch<N>,
11344    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11345        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11346        Ok(self.runtime.ingest_batch(cache, batch)?)
11347    }
11348
11349    /// Ingest one already-polled batch and execute the storage/account resyncs
11350    /// it surfaces, exactly like
11351    /// [`ReactiveRuntime::ingest_batch_with_resync`].
11352    ///
11353    /// # Errors
11354    ///
11355    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11356    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11357    /// runtime ingestion fails.
11358    pub fn ingest_batch_with_resync(
11359        &mut self,
11360        cache: &mut EvmCache,
11361        batch: ReactiveInputBatch<N>,
11362    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11363        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11364        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
11365    }
11366
11367    fn ensure_raw_ingest_is_safe(
11368        &self,
11369        cache: &EvmCache,
11370        batch: &ReactiveInputBatch<N>,
11371    ) -> Result<(), ReactiveEngineError> {
11372        if self.pending_checkpoint.is_some() {
11373            return Err(ReactiveEngineError::PendingCheckpointCommit);
11374        }
11375        if self.pending_acknowledgement.is_some() {
11376            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11377        }
11378        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
11379            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
11380        }
11381        self.ensure_subscriber_chain(cache)?;
11382        Ok(())
11383    }
11384
11385    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
11386        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11387            && subscriber_chain_id != cache.chain_id()
11388        {
11389            return Err(ReactiveEngineError::SubscriberChainMismatch {
11390                subscriber_chain_id,
11391                cache_chain_id: cache.chain_id(),
11392            });
11393        }
11394        Ok(())
11395    }
11396
11397    fn ensure_subscriber_restore_chain(
11398        &self,
11399        checkpoint_chain_id: u64,
11400    ) -> Result<(), ReactiveCheckpointRestoreError> {
11401        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11402            && subscriber_chain_id != checkpoint_chain_id
11403        {
11404            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
11405                subscriber_chain_id,
11406                checkpoint_chain_id,
11407            });
11408        }
11409        Ok(())
11410    }
11411
11412    /// Poll the subscriber once and ingest the returned batch when present
11413    /// (direct effects only).
11414    ///
11415    /// # Errors
11416    ///
11417    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11418    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11419    /// delivery-acknowledgement failure. A failed acknowledgement remains
11420    /// pending and is retried before polling again.
11421    pub async fn next_ingest(
11422        &mut self,
11423        cache: &mut EvmCache,
11424    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11425        self.ensure_subscriber_chain(cache)?;
11426        if self.pending_checkpoint.is_some() {
11427            return Err(ReactiveEngineError::PendingCheckpointCommit);
11428        }
11429        if self.pending_acknowledgement.is_some() {
11430            return self.commit_pending_acknowledgement().await.map(Some);
11431        }
11432        let batch = self.subscriber.next_batch().await?;
11433        self.ensure_subscriber_chain(cache)?;
11434        let Some(mut batch) = batch else {
11435            return Ok(None);
11436        };
11437        let delivery_token = batch.take_delivery_token();
11438        let report = self.runtime.ingest_batch(cache, batch)?;
11439        self.stage_or_return_acknowledgement(delivery_token, report)
11440            .await
11441    }
11442
11443    /// Poll the subscriber once and ingest the returned batch with resync
11444    /// execution — the loop shape for consumers that rely on coverage-gap
11445    /// repair (root-gate resyncs, handler-requested re-reads).
11446    ///
11447    /// # Errors
11448    ///
11449    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11450    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11451    /// delivery-acknowledgement failure. A failed acknowledgement remains
11452    /// pending and is retried before polling again.
11453    pub async fn next_ingest_with_resync(
11454        &mut self,
11455        cache: &mut EvmCache,
11456    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11457        self.ensure_subscriber_chain(cache)?;
11458        if self.pending_checkpoint.is_some() {
11459            return Err(ReactiveEngineError::PendingCheckpointCommit);
11460        }
11461        if self.pending_acknowledgement.is_some() {
11462            return self.commit_pending_acknowledgement().await.map(Some);
11463        }
11464        let batch = self.subscriber.next_batch().await?;
11465        self.ensure_subscriber_chain(cache)?;
11466        let Some(mut batch) = batch else {
11467            return Ok(None);
11468        };
11469        let delivery_token = batch.take_delivery_token();
11470        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
11471        self.stage_or_return_acknowledgement(delivery_token, report)
11472            .await
11473    }
11474
11475    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
11476    ///
11477    /// The ordering is strict: subscriber acknowledgement is never attempted
11478    /// until the complete cache checkpoint is synced. If checkpointing or
11479    /// acknowledgement fails, the in-memory pending commit is retried before
11480    /// any later batch is polled, so a transient disk failure cannot cause the
11481    /// already-applied batch to execute twice in the same process. Across a
11482    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11483    /// uses the stored delivery token and delivery witness to recognize and
11484    /// acknowledge an identical replay without re-ingestion. Reusing a token
11485    /// for different input or cursor state fails closed. Mutating the cache while
11486    /// a commit is pending also fails closed rather than binding newer state to
11487    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
11488    /// that cannot be proven from the retained effect journal is rejected before
11489    /// mutation/save/ACK; configure
11490    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
11491    /// Hooks are dispatched only after checkpoint staging
11492    /// succeeds, but remain in-process observers rather than a durable outbox;
11493    /// see [`ReactiveHook`]. The subscriber must advertise
11494    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
11495    /// rejected before polling.
11496    ///
11497    /// # Errors
11498    ///
11499    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
11500    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
11501    /// an incompatible state, polling or ingestion fails, complete rollback
11502    /// proof is unavailable, the cache changes after staging, persistence
11503    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
11504    /// retained for retry before another poll.
11505    pub async fn next_ingest_checkpointed(
11506        &mut self,
11507        cache: &mut EvmCache,
11508        store: &DurableCheckpointStore,
11509        identity: &DurableCheckpointIdentity,
11510    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11511        if !self.subscriber.capabilities().supports_durable_replay() {
11512            return Err(ReactiveEngineError::SubscriberNotDurable);
11513        }
11514        self.ensure_subscriber_chain(cache)?;
11515        if self.pending_acknowledgement.is_some() {
11516            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11517        }
11518        self.ensure_checkpoint_identity(cache, identity)?;
11519        if self.pending_checkpoint.is_some() {
11520            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11521        }
11522
11523        let batch = self.subscriber.next_batch().await?;
11524        self.ensure_subscriber_chain(cache)?;
11525        let Some(mut batch) = batch else {
11526            return Ok(None);
11527        };
11528        if batch_preconfirmation(&batch)?.is_some() {
11529            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11530        }
11531        self.runtime.discard_preconfirmed_branch(cache);
11532        let delivery_witness = batch
11533            .delivery_token()
11534            .map(|_| durable_delivery_witness(&batch))
11535            .transpose()?;
11536        let delivery_token = batch.take_delivery_token();
11537        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11538        if let (Some(replay_token), Some(committed_token)) = (
11539            delivery_token.as_ref(),
11540            self.last_checkpoint_delivery_token.as_ref(),
11541        ) && replay_token == committed_token
11542        {
11543            let committed_witness = self
11544                .last_checkpoint_delivery_witness
11545                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11546            if delivery_witness != Some(committed_witness) {
11547                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11548            }
11549            self.subscriber
11550                .acknowledge_delivery(replay_token.clone())
11551                .await
11552                .map_err(ReactiveEngineError::Acknowledgement)?;
11553            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11554        }
11555
11556        self.ensure_checkpointable_reorgs(&batch)?;
11557
11558        let incoming_block = latest_canonical_batch_block(&batch);
11559        let cache_state = EvmCacheStateSnapshot::capture(cache);
11560        let runtime_state = self.runtime.checkpoint_state();
11561        let report = match self.runtime.ingest_batch_direct(cache, batch) {
11562            Ok(report) => report,
11563            Err(error) => {
11564                cache_state.restore(cache);
11565                self.runtime.restore_transaction_state(runtime_state);
11566                return Err(error.into());
11567            }
11568        };
11569        let reports = report.reports.clone();
11570        let stage = CheckpointStage {
11571            incoming_block,
11572            delivery_token,
11573            delivery_witness,
11574            subscriber_checkpoint,
11575            staged_generation: cache.snapshot_generation(),
11576            report,
11577        };
11578        if let Err(error) = self.stage_checkpoint(identity, stage) {
11579            cache_state.restore(cache);
11580            self.runtime.restore_transaction_state(runtime_state);
11581            return Err(error);
11582        }
11583        self.runtime.dispatch_reports(&reports);
11584        self.commit_pending_checkpoint(cache, store).await.map(Some)
11585    }
11586
11587    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
11588    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
11589    /// ephemeral subscriber before polling.
11590    ///
11591    /// # Errors
11592    ///
11593    /// Returns [`ReactiveEngineError`] for the same durability, identity,
11594    /// rollback-proof, replay-witness, polling, ingestion, persistence,
11595    /// mutation-fence, and acknowledgement failures as
11596    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
11597    pub async fn next_ingest_with_resync_checkpointed(
11598        &mut self,
11599        cache: &mut EvmCache,
11600        store: &DurableCheckpointStore,
11601        identity: &DurableCheckpointIdentity,
11602    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11603        if !self.subscriber.capabilities().supports_durable_replay() {
11604            return Err(ReactiveEngineError::SubscriberNotDurable);
11605        }
11606        self.ensure_subscriber_chain(cache)?;
11607        if self.pending_acknowledgement.is_some() {
11608            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11609        }
11610        self.ensure_checkpoint_identity(cache, identity)?;
11611        if self.pending_checkpoint.is_some() {
11612            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11613        }
11614
11615        let batch = self.subscriber.next_batch().await?;
11616        self.ensure_subscriber_chain(cache)?;
11617        let Some(mut batch) = batch else {
11618            return Ok(None);
11619        };
11620        if batch_preconfirmation(&batch)?.is_some() {
11621            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11622        }
11623        self.runtime.discard_preconfirmed_branch(cache);
11624        let delivery_witness = batch
11625            .delivery_token()
11626            .map(|_| durable_delivery_witness(&batch))
11627            .transpose()?;
11628        let delivery_token = batch.take_delivery_token();
11629        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11630        if let (Some(replay_token), Some(committed_token)) = (
11631            delivery_token.as_ref(),
11632            self.last_checkpoint_delivery_token.as_ref(),
11633        ) && replay_token == committed_token
11634        {
11635            let committed_witness = self
11636                .last_checkpoint_delivery_witness
11637                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11638            if delivery_witness != Some(committed_witness) {
11639                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11640            }
11641            self.subscriber
11642                .acknowledge_delivery(replay_token.clone())
11643                .await
11644                .map_err(ReactiveEngineError::Acknowledgement)?;
11645            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11646        }
11647
11648        self.ensure_checkpointable_reorgs(&batch)?;
11649
11650        let incoming_block = latest_canonical_batch_block(&batch);
11651        let cache_state = EvmCacheStateSnapshot::capture(cache);
11652        let runtime_state = self.runtime.checkpoint_state();
11653        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
11654            Ok(report) => report,
11655            Err(error) => {
11656                cache_state.restore(cache);
11657                self.runtime.restore_transaction_state(runtime_state);
11658                return Err(error.into());
11659            }
11660        };
11661        let reports = report.reports.clone();
11662        let stage = CheckpointStage {
11663            incoming_block,
11664            delivery_token,
11665            delivery_witness,
11666            subscriber_checkpoint,
11667            staged_generation: cache.snapshot_generation(),
11668            report,
11669        };
11670        if let Err(error) = self.stage_checkpoint(identity, stage) {
11671            cache_state.restore(cache);
11672            self.runtime.restore_transaction_state(runtime_state);
11673            return Err(error);
11674        }
11675        self.runtime.dispatch_reports(&reports);
11676        self.commit_pending_checkpoint(cache, store).await.map(Some)
11677    }
11678
11679    fn stage_checkpoint(
11680        &mut self,
11681        identity: &DurableCheckpointIdentity,
11682        stage: CheckpointStage<N>,
11683    ) -> Result<(), ReactiveEngineError> {
11684        let CheckpointStage {
11685            incoming_block,
11686            delivery_token,
11687            delivery_witness,
11688            subscriber_checkpoint,
11689            staged_generation,
11690            report,
11691        } = stage;
11692        if delivery_token.is_some() != delivery_witness.is_some() {
11693            return Err(ReactiveEngineError::DeliveryWitness(
11694                "delivery token and witness must be staged together".into(),
11695            ));
11696        }
11697        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
11698        let block = self
11699            .runtime
11700            .last_canonical_block()
11701            .map(|block| DurableCheckpointBlock {
11702                number: block.number,
11703                hash: block.hash,
11704                parent_hash: block.parent_hash,
11705                timestamp: block.timestamp,
11706            })
11707            .or(incoming_block)
11708            .or_else(|| self.last_checkpoint_block.clone())
11709            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
11710        let metadata = DurableCheckpointMetadata {
11711            identity: identity.clone(),
11712            block,
11713            delivery_token: delivery_token
11714                .as_ref()
11715                .or(self.last_checkpoint_delivery_token.as_ref())
11716                .map(|token| token.as_bytes().to_vec()),
11717            delivery_witness: if delivery_token.is_some() {
11718                delivery_witness
11719            } else {
11720                self.last_checkpoint_delivery_witness
11721            },
11722            subscriber_checkpoint: subscriber_checkpoint
11723                .as_ref()
11724                .or(self.last_subscriber_checkpoint.as_ref())
11725                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
11726            runtime_checkpoint: Some(runtime_checkpoint),
11727        };
11728        self.pending_checkpoint = Some(PendingCheckpoint {
11729            metadata,
11730            delivery_token,
11731            report,
11732            saved_to: None,
11733            staged_generation,
11734        });
11735        Ok(())
11736    }
11737
11738    fn ensure_checkpointable_reorgs(
11739        &self,
11740        batch: &ReactiveInputBatch<N>,
11741    ) -> Result<(), ReactiveEngineError> {
11742        let state = CanonicalSequenceState::new(
11743            self.runtime
11744                .journal
11745                .iter()
11746                .map(|entry| entry.block)
11747                .collect(),
11748            self.runtime.coverage_head,
11749            self.runtime.safe_head,
11750            self.runtime.finalized_head,
11751        );
11752        match validate_canonical_sequence_internal(
11753            &state,
11754            batch,
11755            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
11756        ) {
11757            Ok(_) => Ok(()),
11758            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
11759            Err(CanonicalSequenceError::IncompleteRollback {
11760                common_ancestor,
11761                oldest_retained,
11762                ..
11763            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
11764                common_ancestor,
11765                oldest_journaled: oldest_retained,
11766                journal_depth: self.runtime.config.journal_depth,
11767            }),
11768        }
11769    }
11770
11771    async fn stage_or_return_acknowledgement(
11772        &mut self,
11773        delivery_token: Option<SubscriberDeliveryToken>,
11774        report: ReactiveBatchReport<N>,
11775    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11776        let Some(token) = delivery_token else {
11777            return Ok(Some(report));
11778        };
11779        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
11780        self.commit_pending_acknowledgement().await.map(Some)
11781    }
11782
11783    async fn commit_pending_acknowledgement(
11784        &mut self,
11785    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11786        let token = self
11787            .pending_acknowledgement
11788            .as_ref()
11789            .expect("caller checked pending acknowledgement")
11790            .token
11791            .clone();
11792        self.subscriber
11793            .acknowledge_delivery(token)
11794            .await
11795            .map_err(ReactiveEngineError::Acknowledgement)?;
11796        Ok(self
11797            .pending_acknowledgement
11798            .take()
11799            .expect("pending acknowledgement remains until commit")
11800            .report)
11801    }
11802
11803    async fn commit_pending_checkpoint(
11804        &mut self,
11805        cache: &EvmCache,
11806        store: &DurableCheckpointStore,
11807    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
11808        let pending = self
11809            .pending_checkpoint
11810            .as_mut()
11811            .expect("caller checked pending checkpoint");
11812        let cache_generation = cache.snapshot_generation();
11813        if cache_generation != pending.staged_generation {
11814            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
11815                staged_generation: pending.staged_generation,
11816                current_generation: cache_generation,
11817            });
11818        }
11819        if pending.saved_to.as_deref() != Some(store.path()) {
11820            store
11821                .save_async(cache, pending.metadata.clone())
11822                .await
11823                .map_err(ReactiveEngineError::Checkpoint)?;
11824            pending.saved_to = Some(store.path().to_path_buf());
11825        }
11826        if let Some(token) = pending.delivery_token.clone() {
11827            self.subscriber
11828                .acknowledge_delivery(token)
11829                .await
11830                .map_err(ReactiveEngineError::Acknowledgement)?;
11831        }
11832
11833        let pending = self
11834            .pending_checkpoint
11835            .take()
11836            .expect("pending checkpoint remains until commit");
11837        self.last_checkpoint_block = Some(pending.metadata.block);
11838        self.checkpoint_identity = Some(pending.metadata.identity);
11839        self.last_checkpoint_delivery_token = pending
11840            .metadata
11841            .delivery_token
11842            .map(SubscriberDeliveryToken::new);
11843        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
11844        self.last_subscriber_checkpoint = pending
11845            .metadata
11846            .subscriber_checkpoint
11847            .map(SubscriberCheckpoint::new);
11848        Ok(CheckpointedIngest::Applied(pending.report))
11849    }
11850
11851    fn ensure_checkpoint_identity(
11852        &self,
11853        cache: &EvmCache,
11854        identity: &DurableCheckpointIdentity,
11855    ) -> Result<(), ReactiveEngineError> {
11856        if identity.chain_id != cache.chain_id() {
11857            return Err(ReactiveEngineError::Checkpoint(
11858                DurableCheckpointError::CacheChainMismatch {
11859                    cache_chain_id: cache.chain_id(),
11860                    checkpoint_chain_id: identity.chain_id,
11861                },
11862            ));
11863        }
11864        if let Some(actual) = self.checkpoint_identity.as_ref()
11865            && actual != identity
11866        {
11867            return Err(ReactiveEngineError::Checkpoint(
11868                DurableCheckpointError::IdentityMismatch {
11869                    expected: identity.clone(),
11870                    actual: actual.clone(),
11871                },
11872            ));
11873        }
11874        if let Some(pending) = self.pending_checkpoint.as_ref()
11875            && &pending.metadata.identity != identity
11876        {
11877            return Err(ReactiveEngineError::Checkpoint(
11878                DurableCheckpointError::IdentityMismatch {
11879                    expected: identity.clone(),
11880                    actual: pending.metadata.identity.clone(),
11881                },
11882            ));
11883        }
11884        Ok(())
11885    }
11886}
11887
11888fn latest_canonical_batch_block<N: Network>(
11889    batch: &ReactiveInputBatch<N>,
11890) -> Option<DurableCheckpointBlock> {
11891    let record_block = batch
11892        .records()
11893        .iter()
11894        .enumerate()
11895        .filter(|(index, _)| {
11896            batch
11897                .record_delivery_scope(*index)
11898                .is_some_and(DeliveryScope::advances_canonical_state)
11899        })
11900        .filter_map(|(_, record)| canonical_record_block(record))
11901        .max_by_key(|block| block.number)
11902        .cloned();
11903    let control_block = batch
11904        .chain_controls()
11905        .iter()
11906        .filter_map(|control| match control {
11907            ChainControl::Reorg {
11908                common_ancestor, ..
11909            } => Some(common_ancestor),
11910            ChainControl::Barrier {
11911                block: Some(block), ..
11912            }
11913            | ChainControl::CanonicalProgress(block) => Some(block),
11914            ChainControl::Safe(_)
11915            | ChainControl::Finalized(_)
11916            | ChainControl::Barrier { block: None, .. } => None,
11917        })
11918        .max_by_key(|block| block.number)
11919        .cloned();
11920
11921    record_block
11922        .into_iter()
11923        .chain(control_block)
11924        .max_by_key(|block| block.number)
11925        .map(|block| DurableCheckpointBlock {
11926            number: block.number,
11927            hash: block.hash,
11928            parent_hash: block.parent_hash,
11929            timestamp: block.timestamp,
11930        })
11931}
11932
11933impl<S, N> ReactiveEngine<S, N>
11934where
11935    N: Network,
11936    S: InterestOwnerSubscriber<N>,
11937{
11938    /// Register a handler with both the runtime and subscriber, backfilling its
11939    /// log interests from the runtime's last canonical block.
11940    ///
11941    /// This is the continuity-safe default for mid-lifecycle registration. The
11942    /// subscriber adopts the live desired state first, delivers the new owner's
11943    /// matching records at retained block `C` as owner catch-up, then delivers
11944    /// `C + 1` through activation as global canonical catch-up over the complete
11945    /// handler union. No discovery gap opens, and every effect after `C` enters
11946    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
11947    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
11948    /// pre-ingestion bootstrap. Use
11949    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
11950    /// for an explicit replay of one retained block or
11951    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
11952    /// out of backfill entirely.
11953    ///
11954    /// Subscriber registration commits before runtime routing is installed. If
11955    /// the subscriber operation fails or is cancelled, the runtime remains
11956    /// unchanged.
11957    ///
11958    /// # Errors
11959    ///
11960    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
11961    /// registered or the subscriber rejects/does not support the required
11962    /// owner update or coordinated catch-up.
11963    pub async fn register_handler(
11964        &mut self,
11965        handler: Arc<dyn ReactiveHandler<N>>,
11966    ) -> Result<(), ReactiveEngineRegisterError> {
11967        let backfill = self
11968            .runtime
11969            .last_canonical_block()
11970            .filter(|retained| {
11971                self.runtime.journal.iter().any(|entry| {
11972                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
11973                })
11974            })
11975            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
11976            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
11977        self.register_handler_inner(handler, backfill).await
11978    }
11979
11980    /// Register a handler and replay its matching logs at one exact retained
11981    /// canonical block.
11982    ///
11983    /// Owner-only effects are appended to that block's existing rollback
11984    /// journal entry. Consequently this method accepts only a bounded
11985    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
11986    /// anchor all identify the same journaled block. Wider/deeper recovery must
11987    /// use ordinary global canonical ingestion (for example startup catch-up),
11988    /// where every handler sees the records and the runtime advances coverage.
11989    ///
11990    /// If subscriber registration fails or is cancelled, the runtime remains
11991    /// unchanged.
11992    ///
11993    /// # Errors
11994    ///
11995    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
11996    /// registered, the requested backfill is not exactly one hash-certified
11997    /// retained journal block, or the subscriber update fails.
11998    pub async fn register_handler_with_backfill(
11999        &mut self,
12000        handler: Arc<dyn ReactiveHandler<N>>,
12001        backfill: SubscriberBackfill,
12002    ) -> Result<(), ReactiveEngineRegisterError> {
12003        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
12004            .await
12005    }
12006
12007    /// Register a handler without any log backfill — only logs delivered after
12008    /// its live subscription starts are routed to it.
12009    ///
12010    /// If subscriber registration fails or is cancelled, the runtime remains
12011    /// unchanged.
12012    ///
12013    /// # Errors
12014    ///
12015    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12016    /// registered or the subscriber cannot commit the owner update.
12017    pub async fn register_handler_live_only(
12018        &mut self,
12019        handler: Arc<dyn ReactiveHandler<N>>,
12020    ) -> Result<(), ReactiveEngineRegisterError> {
12021        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
12022            .await
12023    }
12024
12025    async fn register_handler_inner(
12026        &mut self,
12027        handler: Arc<dyn ReactiveHandler<N>>,
12028        catchup: HandlerRegistrationCatchup,
12029    ) -> Result<(), ReactiveEngineRegisterError> {
12030        let id = handler.id();
12031        if self.runtime.contains_handler(&id) {
12032            return Err(RegisterError::DuplicateHandler(id).into());
12033        }
12034        let interests = handler.interests();
12035
12036        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
12037            let retained_anchor = backfill.retained_anchor().copied();
12038            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
12039                backfill.start_block() == anchor.number
12040                    && backfill.end_block() == Some(anchor.number)
12041                    && self.runtime.journal.iter().any(|entry| {
12042                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
12043                    })
12044            });
12045            if !is_exact_retained_block {
12046                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
12047                    start_block: backfill.start_block(),
12048                    end_block: backfill.end_block(),
12049                    retained_anchor,
12050                });
12051            }
12052        }
12053
12054        let subscribed = match catchup {
12055            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
12056                self.subscriber
12057                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
12058                    .await
12059            }
12060            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
12061                self.subscriber
12062                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
12063                    .await
12064            }
12065            HandlerRegistrationCatchup::LiveOnly => {
12066                self.subscriber
12067                    .add_interest_owner(id.clone(), &interests)
12068                    .await
12069            }
12070        };
12071        if let Err(error) = subscribed {
12072            return Err(error.into());
12073        }
12074
12075        // `&mut self` excludes concurrent registry mutation between the
12076        // duplicate preflight and this commit. Registration is deliberately
12077        // subscriber-first: cancelling the awaited operation cannot leave a
12078        // runtime handler active without committed subscriber interests.
12079        self.runtime
12080            .registry
12081            .insert_handler_prepared(id, handler, interests);
12082        Ok(())
12083    }
12084
12085    /// Register every handler currently in the runtime registry as a subscriber
12086    /// interest owner.
12087    ///
12088    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
12089    /// before ingestion starts, or for reattaching an already-aligned durable
12090    /// subscriber whose exact owner state was restored independently. Each
12091    /// handler becomes its own owner through one exact bulk replacement;
12092    /// crash-stale owners and unowned/base interests are removed.
12093    ///
12094    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
12095    /// potentially stale subscriber after the runtime has processed canonical
12096    /// state: use
12097    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
12098    /// which exact-replaces the owner set and closes continuity from the
12099    /// restored runtime position.
12100    ///
12101    /// The complete exact set commits through one subscriber operation; an
12102    /// error or cancellation leaves the previously committed topology
12103    /// authoritative.
12104    ///
12105    /// # Errors
12106    ///
12107    /// Returns [`SubscriberError`] when the subscriber cannot atomically
12108    /// replace the complete owner topology.
12109    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
12110        let owners = self
12111            .runtime
12112            .handler_ids()
12113            .into_iter()
12114            .map(|id| {
12115                let interests = self
12116                    .runtime
12117                    .handler_interests(&id)
12118                    .map(<[ReactiveInterest<N>]>::to_vec)
12119                    .unwrap_or_default();
12120                (id, interests)
12121            })
12122            .collect();
12123        self.subscriber.replace_interest_owners(owners).await
12124    }
12125
12126    /// Rebuild subscriber owner state from a runtime that already embodies a
12127    /// canonical checkpoint.
12128    ///
12129    /// The runtime registry is authoritative: the subscriber must atomically
12130    /// replace its complete owner set, removing crash-stale owners as well as
12131    /// adding the current ones. Log catch-up is routed globally through normal
12132    /// canonical ingestion and begins strictly at `C + 1`, where
12133    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
12134    /// cache already contains every effect through `C`. The exact number/hash
12135    /// identity of `C` remains attached as a retained baseline and must be
12136    /// validated by the subscriber before it exposes post-baseline records.
12137    /// Global routing is essential: startup catch-up effects enter the ordinary
12138    /// canonical journal and can be rolled back if the certified branch later
12139    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
12140    /// handler addition.
12141    ///
12142    /// A runtime without a canonical position must use
12143    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
12144    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
12145    /// The replacement is one subscriber commit boundary: errors and
12146    /// cancellation leave the previous topology authoritative.
12147    ///
12148    /// # Errors
12149    ///
12150    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
12151    /// exists or no exclusive successor can be represented, and otherwise
12152    /// propagates subscriber validation, transport, or atomic-commit failures.
12153    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
12154        let baseline =
12155            self.runtime
12156                .last_canonical_block()
12157                .ok_or(SubscriberError::InvalidConfig(
12158                    "cannot continuity-sync handlers before a canonical runtime position exists",
12159                ))?;
12160        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
12161        let owners = self
12162            .runtime
12163            .handler_ids()
12164            .into_iter()
12165            .map(|id| {
12166                let interests = self
12167                    .runtime
12168                    .handler_interests(&id)
12169                    .map(<[ReactiveInterest<N>]>::to_vec)
12170                    .unwrap_or_default();
12171                (id, interests)
12172            })
12173            .collect();
12174        self.subscriber
12175            .replace_interest_owners_with_global_backfill(owners, backfill)
12176            .await
12177    }
12178
12179    /// Unregister a handler from both the subscriber and runtime.
12180    ///
12181    /// Subscriber interests are removed first so no new live records are routed
12182    /// to a handler after it has left the runtime registry. Returns the removed
12183    /// handler when the id was registered. If subscriber removal fails or is
12184    /// cancelled, runtime routing remains installed.
12185    ///
12186    /// This is the routing/transport half of dropping an adapter. State the
12187    /// handler accumulated is deliberately left in place; the complete teardown
12188    /// for a pool or adapter that will not return is:
12189    ///
12190    /// ```text
12191    /// engine.unregister_handler(&id).await?;
12192    /// for request_id in handler_request_ids {
12193    ///     // Drop only this handler generation's queued repair work.
12194    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
12195    /// }
12196    /// for address in exclusively_owned_addresses {
12197    ///     // Shared accounts require caller-side owner reference counting.
12198    ///     engine.runtime_mut().untrack_account(address);
12199    /// }
12200    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
12201    /// ```
12202    ///
12203    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
12204    /// runtime-global and are never touched by handler removal.
12205    ///
12206    /// # Errors
12207    ///
12208    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
12209    /// removal. In that case runtime routing remains installed.
12210    pub async fn unregister_handler(
12211        &mut self,
12212        id: &HandlerId,
12213    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
12214        self.subscriber.remove_interest_owner(id).await?;
12215        Ok(self.runtime.unregister_handler(id))
12216    }
12217}
12218
12219type FlashblockReconnectFuture<N> = Pin<
12220    Box<
12221        dyn Future<
12222                Output = (
12223                    SubscriberStreamSource,
12224                    Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
12225                ),
12226            > + Send,
12227    >,
12228>;
12229
12230/// Alloy-backed event subscriber.
12231///
12232/// The default transport slice drives Alloy pubsub subscriptions for logs,
12233/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
12234/// transport remains available behind the opt-in `reactive-polling` feature.
12235/// Pubsub streams reconnect automatically after termination, and log
12236/// subscriptions are backfilled from the last seen block. Owner-scoped log
12237/// additions can request backfill from an explicit block anchor. Full pending
12238/// transaction hydration and full block bodies remain explicit follow-up work.
12239///
12240/// Historical log fetching is deliberately a bounded live-subscriber aid, not
12241/// a high-volume indexer: each filter/window is issued as one complete-range
12242/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
12243/// an oversized decoded response, but the subscriber does not adaptively split
12244/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
12245/// registration and reconnect windows modest; use an indexing source such as
12246/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
12247///
12248/// With no registered interests, [`EventSubscriber::next_batch`] returns
12249/// `Ok(None)`.
12250pub struct AlloySubscriber<P, N: Network = Ethereum> {
12251    provider: P,
12252    /// Optional request/response half of the same configured provider lease.
12253    /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC
12254    /// does not expose the provider's pending-state surface.
12255    flashblocks_state_provider: Option<P>,
12256    /// Stable identity for the provider session used by Flashblocks and every
12257    /// follow-up pending-state read.
12258    provider_ref: Option<ProviderRef>,
12259    /// Optional provider dedicated to canonical log-context verification.
12260    /// Keeping this separate prevents a high-volume pubsub connection from
12261    /// starving its own verification requests behind log notifications.
12262    log_verification_provider: Option<P>,
12263    /// Provider chain identity, resolved once before any record can escape.
12264    chain_id: Option<u64>,
12265    mode: SubscriberMode,
12266    config: SubscriberConfig,
12267    base_interests: Vec<ReactiveInterest<N>>,
12268    owned_interests: Vec<OwnedSubscriberInterests<N>>,
12269    next_owner_epoch: u64,
12270    interests: Vec<ReactiveInterest<N>>,
12271    /// Stable source id per distinct provider-facing log filter. Ids key
12272    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
12273    /// their anchors pruned) when no planned stream references the filter, so
12274    /// long-lived owner churn cannot grow this map unboundedly.
12275    log_source_ids: HashMap<Filter, usize>,
12276    next_log_source_id: usize,
12277    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
12278    /// Successfully connected sources whose subscribe-then-backfill step has
12279    /// not committed yet. Installation happens before the backfill await, so a
12280    /// cancelled reconcile keeps the live stream and retries only the missing
12281    /// historical window.
12282    pending_source_backfills: VecDeque<SubscriberStreamSource>,
12283    /// Set when interest bookkeeping changed since the last successful stream
12284    /// reconcile, so steady-state polling skips the desired-vs-live diff.
12285    sources_dirty: bool,
12286    /// Conservative generation of desired/live stream topology. Successful
12287    /// owner progress is activatable only against the same clean revision.
12288    stream_revision: u64,
12289    state: AlloySubscriberState<N>,
12290    pending_records: VecDeque<SubscriberInputRecord<N>>,
12291    pending_chain_controls: VecDeque<ChainControl>,
12292    /// Owner copies of live records consumed during an in-flight reconcile.
12293    /// These remain hidden from subscriber output until the owning reconcile
12294    /// commits and survive cancellation so subscribe-first adoption cannot
12295    /// lose an event at an await boundary.
12296    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
12297    /// Sticky fail-closed capacity error. Once an event could not be retained,
12298    /// only a full replacement registration can establish a new baseline.
12299    resource_error: Option<String>,
12300    last_seen_log_blocks: HashMap<usize, u64>,
12301    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
12302    verified_log_block_order: VecDeque<(u64, B256)>,
12303    recent_input_refs: VecDeque<InputRef>,
12304    recent_input_ref_set: HashSet<InputRef>,
12305    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
12306    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
12307    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
12308    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
12309    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
12310    base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
12311    unmatched_pending_logs: VecDeque<(usize, Log)>,
12312    latest_preconfirmation: Option<FlashblockRef>,
12313    preconfirmed_seen_logs: HashSet<(B256, u64)>,
12314    /// OP transaction receipts already proven for the active cumulative
12315    /// payload. This avoids re-querying non-matching transactions while still
12316    /// retrying receipts that were temporarily unavailable.
12317    preconfirmed_receipted_transactions: HashSet<B256>,
12318    /// OP receipt hashes that returned `null` at least once for the active
12319    /// payload. Never-attempted hashes are scheduled ahead of this retry set so
12320    /// a lagging provider cache cannot let a few transactions monopolize the
12321    /// bounded request budget.
12322    preconfirmed_unavailable_receipts: HashSet<B256>,
12323    last_certified_canonical_head: Option<BlockRef>,
12324    pending_preconfirmation_invalidation: bool,
12325    pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
12326    pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
12327    flashblocks_rpc_metrics: FlashblocksRpcMetrics,
12328    consecutive_flashblock_poll_failures: usize,
12329    flashblock_rpc_request_times: VecDeque<Instant>,
12330    _network: PhantomData<N>,
12331}
12332
12333struct OwnedSubscriberInterests<N: Network = Ethereum> {
12334    owner: HandlerId,
12335    interests: Vec<ReactiveInterest<N>>,
12336    epoch: Option<SubscriberOwnerEpoch>,
12337    state: SubscriberOwnerState,
12338    baseline: Option<BlockRef>,
12339    progress: Option<SubscriberOwnerProgress>,
12340    progress_stream_revision: Option<u64>,
12341}
12342
12343#[derive(Clone)]
12344struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
12345    epoch: SubscriberOwnerEpoch,
12346    interests: Vec<ReactiveInterest<N>>,
12347    retained: BlockRef,
12348    from_block: u64,
12349}
12350
12351struct SubscriberOwnerCatchup {
12352    logs: Vec<Log>,
12353    certified: BlockRef,
12354}
12355
12356#[derive(Clone, Copy)]
12357struct SubscriberOwnerCatchupOptions {
12358    target_preverified: bool,
12359    max_logs: usize,
12360    max_log_bytes: usize,
12361    max_requests_in_flight: usize,
12362}
12363
12364struct SubscriberOwnerReconcileFilter {
12365    filter: Filter,
12366    from_block: u64,
12367}
12368
12369struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
12370    record: ReactiveInputRecord<N>,
12371    owners: Vec<SubscriberOwnerEpoch>,
12372}
12373
12374const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
12375
12376struct QueuedSubscriberBackfill {
12377    /// `None` means global canonical catch-up; `Some` is compatibility
12378    /// owner-only catch-up for true mid-lifecycle additions.
12379    owner: Option<HandlerId>,
12380    epoch: Option<SubscriberOwnerEpoch>,
12381    /// Complete logical filter set for one certified, globally ordered window.
12382    filters: Vec<Filter>,
12383    backfill: SubscriberBackfill,
12384}
12385
12386/// Best-effort installation of rustls' `ring` crypto provider as the process
12387/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
12388/// "no process-level CryptoProvider available". Runs at most once and ignores the
12389/// error if a default provider is already installed (the host app may have set
12390/// its own).
12391#[cfg(feature = "reactive-ws")]
12392fn ensure_ring_crypto_provider() {
12393    use std::sync::Once;
12394    static INSTALL: Once = Once::new();
12395    INSTALL.call_once(|| {
12396        let _ = rustls::crypto::ring::default_provider().install_default();
12397    });
12398}
12399
12400impl<P, N: Network> AlloySubscriber<P, N> {
12401    /// Create a new Alloy subscriber.
12402    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
12403        #[cfg(feature = "reactive-ws")]
12404        ensure_ring_crypto_provider();
12405        Self {
12406            provider,
12407            flashblocks_state_provider: None,
12408            provider_ref: None,
12409            log_verification_provider: None,
12410            chain_id: None,
12411            mode,
12412            config,
12413            base_interests: Vec::new(),
12414            owned_interests: Vec::new(),
12415            next_owner_epoch: 0,
12416            interests: Vec::new(),
12417            log_source_ids: HashMap::new(),
12418            next_log_source_id: 0,
12419            pending_backfills: VecDeque::new(),
12420            pending_source_backfills: VecDeque::new(),
12421            sources_dirty: true,
12422            stream_revision: 0,
12423            state: AlloySubscriberState::Uninitialized,
12424            pending_records: VecDeque::new(),
12425            pending_chain_controls: VecDeque::new(),
12426            pending_reconcile_owner_records: VecDeque::new(),
12427            resource_error: None,
12428            last_seen_log_blocks: HashMap::new(),
12429            verified_log_blocks: HashMap::new(),
12430            verified_log_block_order: VecDeque::new(),
12431            recent_input_refs: VecDeque::new(),
12432            recent_input_ref_set: HashSet::new(),
12433            recent_owner_input_refs: HashMap::new(),
12434            recent_owner_input_ref_sets: HashMap::new(),
12435            recent_compat_owner_input_refs: HashMap::new(),
12436            recent_compat_owner_input_ref_sets: HashMap::new(),
12437            base_flashblock_header: None,
12438            base_flashblock_transactions: None,
12439            unmatched_pending_logs: VecDeque::new(),
12440            latest_preconfirmation: None,
12441            preconfirmed_seen_logs: HashSet::new(),
12442            preconfirmed_receipted_transactions: HashSet::new(),
12443            preconfirmed_unavailable_receipts: HashSet::new(),
12444            last_certified_canonical_head: None,
12445            pending_preconfirmation_invalidation: false,
12446            pending_flashblock_reconnects: FuturesUnordered::new(),
12447            pending_flashblock_reconnect_sources: Vec::new(),
12448            flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
12449            consecutive_flashblock_poll_failures: 0,
12450            flashblock_rpc_request_times: VecDeque::new(),
12451            _network: PhantomData,
12452        }
12453    }
12454
12455    /// Borrow the provider.
12456    pub fn provider(&self) -> &P {
12457        &self.provider
12458    }
12459
12460    /// Bind this subscriber to the concrete provider lease that supplies
12461    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
12462    /// with the single `flashblocks = true` flag.
12463    #[must_use]
12464    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
12465        self.provider_ref = Some(provider);
12466        self
12467    }
12468
12469    /// Pair the subscriber's event transport with the request/response
12470    /// transport for the same configured provider ID and generation.
12471    ///
12472    /// Optimism pending block/log sampling uses this provider. Preflight reads
12473    /// its chain ID and rejects a mismatch before pending data can be emitted.
12474    /// Use type-erased Alloy providers when the WebSocket and HTTP transports
12475    /// have different concrete Rust types.
12476    #[must_use]
12477    pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
12478        self.flashblocks_state_provider = Some(provider);
12479        self
12480    }
12481
12482    /// Use a separate provider for canonical log-context verification.
12483    ///
12484    /// This is recommended with
12485    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
12486    /// deployments. The provider must target the same chain; every fetched
12487    /// block is still checked against the log's number, hash, and timestamp.
12488    #[must_use]
12489    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
12490        self.log_verification_provider = Some(provider);
12491        self
12492    }
12493
12494    /// Subscriber mode.
12495    pub fn mode(&self) -> SubscriberMode {
12496        self.mode
12497    }
12498
12499    /// Subscriber config.
12500    pub fn config(&self) -> &SubscriberConfig {
12501        &self.config
12502    }
12503
12504    /// Request/response traffic issued for Flashblocks qualification and
12505    /// pending-state sampling since the last full interest reset.
12506    pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
12507        self.flashblocks_rpc_metrics
12508    }
12509
12510    /// Registered interests across base and owner-scoped registrations.
12511    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
12512        &self.interests
12513    }
12514
12515    /// Stage a fresh, epoch-scoped interest owner without making its inputs
12516    /// canonically routable yet.
12517    ///
12518    /// The returned token is required by every later lifecycle operation. A
12519    /// staged owner participates in provider subscription planning immediately,
12520    /// while its matching input remains owner-scoped until
12521    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
12522    /// Post-block owners require hash-certified
12523    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
12524    /// the current clean stream revision before activation.
12525    ///
12526    /// # Errors
12527    ///
12528    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12529    /// duplicate owners, unsupported post-block interests, unsupported
12530    /// transport interests, block-number overflow, or epoch exhaustion.
12531    pub fn stage_interest_owner(
12532        &mut self,
12533        owner: HandlerId,
12534        interests: &[ReactiveInterest<N>],
12535        start: SubscriberOwnerStart,
12536    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12537        validate_subscriber_config(&self.config)?;
12538        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12539            && interests
12540                .iter()
12541                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12542        {
12543            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12544        }
12545        if self
12546            .owned_interests
12547            .iter()
12548            .any(|entry| entry.owner == owner)
12549        {
12550            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12551        }
12552
12553        let mut next_owned = self.clone_owned_interests();
12554        next_owned.push(OwnedSubscriberInterests {
12555            owner: owner.clone(),
12556            interests: interests.to_vec(),
12557            epoch: None,
12558            state: SubscriberOwnerState::Staged,
12559            baseline: None,
12560            progress: None,
12561            progress_stream_revision: None,
12562        });
12563        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12564        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12565
12566        let baseline = match start {
12567            SubscriberOwnerStart::Live => None,
12568            SubscriberOwnerStart::PostBlock(block) => {
12569                block
12570                    .number
12571                    .checked_add(1)
12572                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12573                Some(block)
12574            }
12575        };
12576        let sequence = self
12577            .next_owner_epoch
12578            .checked_add(1)
12579            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12580        let epoch = SubscriberOwnerEpoch {
12581            owner: owner.clone(),
12582            sequence,
12583        };
12584
12585        self.next_owner_epoch = sequence;
12586        let entry = next_owned
12587            .last_mut()
12588            .expect("staged owner was appended during preflight");
12589        entry.epoch = Some(epoch.clone());
12590        entry.baseline = baseline;
12591        self.owned_interests = next_owned;
12592        self.interests = next_registered;
12593        self.sources_dirty = true;
12594
12595        Ok(epoch)
12596    }
12597
12598    /// Stage replacement interests for one currently active logical owner.
12599    ///
12600    /// The active epoch remains canonical while the replacement reconciles.
12601    /// Commit both epochs atomically with
12602    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
12603    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
12604    ///
12605    /// # Errors
12606    ///
12607    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12608    /// missing/non-unique active owner state, unsupported post-block interests,
12609    /// unsupported transport interests, block-number overflow, or epoch
12610    /// exhaustion.
12611    pub fn stage_interest_owner_replacement(
12612        &mut self,
12613        owner: HandlerId,
12614        interests: &[ReactiveInterest<N>],
12615        start: SubscriberOwnerStart,
12616    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12617        validate_subscriber_config(&self.config)?;
12618        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12619            && interests
12620                .iter()
12621                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12622        {
12623            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12624        }
12625        let active_count = self
12626            .owned_interests
12627            .iter()
12628            .filter(|entry| {
12629                entry.owner == owner
12630                    && entry.state == SubscriberOwnerState::Active
12631                    && entry.epoch.is_some()
12632            })
12633            .count();
12634        if active_count != 1
12635            || self
12636                .owned_interests
12637                .iter()
12638                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
12639        {
12640            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12641        }
12642
12643        let mut next_owned = self.clone_owned_interests();
12644        next_owned.push(OwnedSubscriberInterests {
12645            owner: owner.clone(),
12646            interests: interests.to_vec(),
12647            epoch: None,
12648            state: SubscriberOwnerState::Staged,
12649            baseline: None,
12650            progress: None,
12651            progress_stream_revision: None,
12652        });
12653        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12654        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12655
12656        let baseline = match start {
12657            SubscriberOwnerStart::Live => None,
12658            SubscriberOwnerStart::PostBlock(block) => {
12659                block
12660                    .number
12661                    .checked_add(1)
12662                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12663                Some(block)
12664            }
12665        };
12666        let sequence = self
12667            .next_owner_epoch
12668            .checked_add(1)
12669            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12670        let epoch = SubscriberOwnerEpoch {
12671            owner: owner.clone(),
12672            sequence,
12673        };
12674
12675        self.next_owner_epoch = sequence;
12676        let entry = next_owned
12677            .last_mut()
12678            .expect("staged replacement owner was appended during preflight");
12679        entry.epoch = Some(epoch.clone());
12680        entry.baseline = baseline;
12681        self.owned_interests = next_owned;
12682        self.interests = next_registered;
12683        self.sources_dirty = true;
12684        Ok(epoch)
12685    }
12686
12687    /// Current transaction state for an exact owner epoch.
12688    pub fn interest_owner_state(
12689        &self,
12690        epoch: &SubscriberOwnerEpoch,
12691    ) -> Option<SubscriberOwnerState> {
12692        self.owned_interests
12693            .iter()
12694            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12695            .map(|entry| entry.state)
12696    }
12697
12698    /// Latest hash-certified reconcile progress for an exact owner epoch.
12699    pub fn interest_owner_progress(
12700        &self,
12701        epoch: &SubscriberOwnerEpoch,
12702    ) -> Option<&SubscriberOwnerProgress> {
12703        self.owned_interests
12704            .iter()
12705            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12706            .and_then(|entry| entry.progress.as_ref())
12707    }
12708
12709    /// Make a staged owner canonical after its actor-side installation commits.
12710    ///
12711    /// Returns `false` for stale tokens and owners not currently staged.
12712    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12713        let stream_revision = self.stream_revision;
12714        let sources_dirty = self.sources_dirty;
12715        let Some(entry) = self
12716            .owned_interests
12717            .iter_mut()
12718            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12719        else {
12720            return false;
12721        };
12722        if entry.state != SubscriberOwnerState::Staged
12723            || (entry.baseline.is_some()
12724                && (entry.progress.is_none()
12725                    || entry.progress_stream_revision != Some(stream_revision)
12726                    || sources_dirty))
12727        {
12728            return false;
12729        }
12730        entry.state = SubscriberOwnerState::Active;
12731        true
12732    }
12733
12734    /// Atomically replace one active owner epoch with one reconciled staged epoch.
12735    pub fn commit_interest_owner_replacement(
12736        &mut self,
12737        active: &SubscriberOwnerEpoch,
12738        replacement: &SubscriberOwnerEpoch,
12739    ) -> bool {
12740        let Some(active_index) = self
12741            .owned_interests
12742            .iter()
12743            .position(|entry| entry.epoch.as_ref() == Some(active))
12744        else {
12745            return false;
12746        };
12747        let Some(replacement_index) = self
12748            .owned_interests
12749            .iter()
12750            .position(|entry| entry.epoch.as_ref() == Some(replacement))
12751        else {
12752            return false;
12753        };
12754        if active_index == replacement_index
12755            || active.owner() != replacement.owner()
12756            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
12757            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
12758            || (self.owned_interests[replacement_index].baseline.is_some()
12759                && (self.owned_interests[replacement_index].progress.is_none()
12760                    || self.owned_interests[replacement_index].progress_stream_revision
12761                        != Some(self.stream_revision)
12762                    || self.sources_dirty))
12763        {
12764            return false;
12765        }
12766
12767        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
12768        self.owned_interests.remove(active_index);
12769        self.purge_owner_epoch(active);
12770        self.rebuild_registered_interests();
12771        self.retire_unreferenced_filters();
12772        self.sources_dirty = true;
12773        true
12774    }
12775
12776    /// Prepare an exact active owner for removal without changing desired
12777    /// interests, streams, anchors, or queued canonical input.
12778    ///
12779    /// The caller establishes its delivery fence after this transition. Use
12780    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
12781    /// on actor-side failure, or
12782    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
12783    /// once canonical routing has been removed.
12784    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12785        let Some(entry) = self
12786            .owned_interests
12787            .iter_mut()
12788            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12789        else {
12790            return false;
12791        };
12792        if entry.state != SubscriberOwnerState::Active {
12793            return false;
12794        }
12795        entry.state = SubscriberOwnerState::Removing;
12796        true
12797    }
12798
12799    /// Finalize a previously prepared exact owner removal.
12800    ///
12801    /// Returns the removed interests, or `None` for stale tokens and owners not
12802    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
12803    /// is therefore idempotent.
12804    pub fn finalize_interest_owner_removal(
12805        &mut self,
12806        epoch: &SubscriberOwnerEpoch,
12807    ) -> Option<Vec<ReactiveInterest<N>>> {
12808        let index = self.owned_interests.iter().position(|entry| {
12809            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
12810        })?;
12811        let removed = self.owned_interests.remove(index).interests;
12812        self.purge_owner_epoch(epoch);
12813        self.rebuild_registered_interests();
12814        self.retire_unreferenced_filters();
12815        self.sources_dirty = true;
12816        Some(removed)
12817    }
12818
12819    /// Abort an epoch-scoped owner lifecycle operation.
12820    ///
12821    /// A staged owner is removed completely. A prepared removal is restored to
12822    /// active. Active and unknown epochs are unchanged. Repeating the same
12823    /// abort is therefore safe and returns `false` after the first effect.
12824    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12825        let Some(index) = self
12826            .owned_interests
12827            .iter()
12828            .position(|entry| entry.epoch.as_ref() == Some(epoch))
12829        else {
12830            return false;
12831        };
12832        match self.owned_interests[index].state {
12833            SubscriberOwnerState::Staged => {
12834                self.owned_interests.remove(index);
12835                self.purge_owner_epoch(epoch);
12836                self.rebuild_registered_interests();
12837                self.retire_unreferenced_filters();
12838                self.sources_dirty = true;
12839                true
12840            }
12841            SubscriberOwnerState::Removing => {
12842                self.owned_interests[index].state = SubscriberOwnerState::Active;
12843                true
12844            }
12845            SubscriberOwnerState::Active => false,
12846        }
12847    }
12848
12849    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
12850        self.pending_backfills
12851            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
12852        self.pending_records
12853            .retain_mut(|pending| match &mut pending.scope {
12854                SubscriberInputScope::Canonical { owners }
12855                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
12856                    owners.retain(|owner| owner != epoch);
12857                    true
12858                }
12859                SubscriberInputScope::OwnerOnly { owners } => {
12860                    owners.retain(|owner| owner != epoch);
12861                    !owners.is_empty()
12862                }
12863                SubscriberInputScope::OwnerOnlyHandlers { .. }
12864                | SubscriberInputScope::Preconfirmed => true,
12865            });
12866        self.pending_reconcile_owner_records.retain_mut(|pending| {
12867            pending.owners.retain(|owner| owner != epoch);
12868            !pending.owners.is_empty()
12869        });
12870        self.recent_owner_input_refs.remove(epoch);
12871        self.recent_owner_input_ref_sets.remove(epoch);
12872    }
12873
12874    /// Atomically add or replace several owners while preserving unrelated ones.
12875    ///
12876    /// # Errors
12877    ///
12878    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12879    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
12880    /// exhaustion. No owner state changes on error.
12881    pub fn upsert_interest_owners(
12882        &mut self,
12883        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12884    ) -> Result<(), SubscriberError> {
12885        self.upsert_interest_owners_inner(owners, None)
12886    }
12887
12888    /// Atomically add or replace several owners and queue one common backfill
12889    /// policy for every log interest while preserving unrelated owners.
12890    ///
12891    /// # Errors
12892    ///
12893    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
12894    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
12895    /// exhaustion. No owner or backfill state changes on error.
12896    pub fn upsert_interest_owners_with_backfill(
12897        &mut self,
12898        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12899        backfill: SubscriberBackfill,
12900    ) -> Result<(), SubscriberError> {
12901        self.upsert_interest_owners_inner(owners, Some(backfill))
12902    }
12903
12904    fn upsert_interest_owners_inner(
12905        &mut self,
12906        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
12907        explicit_backfill: Option<SubscriberBackfill>,
12908    ) -> Result<(), SubscriberError> {
12909        validate_subscriber_config(&self.config)?;
12910        let mut seen = HashSet::with_capacity(owners.len());
12911        let mut next_owned = self.clone_owned_interests();
12912        for (owner, interests) in &owners {
12913            if !seen.insert(owner.clone()) {
12914                return Err(SubscriberError::InvalidConfig(
12915                    "bulk owner upsert contains a duplicate owner",
12916                ));
12917            }
12918            if self
12919                .owned_interests
12920                .iter()
12921                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
12922            {
12923                return Err(SubscriberError::InvalidConfig(
12924                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
12925                ));
12926            }
12927            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
12928                entry.interests = interests.clone();
12929                entry.state = SubscriberOwnerState::Active;
12930                entry.baseline = None;
12931                entry.progress = None;
12932                entry.progress_stream_revision = None;
12933            } else {
12934                next_owned.push(OwnedSubscriberInterests {
12935                    owner: owner.clone(),
12936                    interests: interests.clone(),
12937                    epoch: None,
12938                    state: SubscriberOwnerState::Active,
12939                    baseline: None,
12940                    progress: None,
12941                    progress_stream_revision: None,
12942                });
12943            }
12944        }
12945        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12946        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12947
12948        // Build every owner's replacement queue before the first mutation.
12949        // Besides keeping capacity failure atomic, this preserves continuity
12950        // for changed filter shapes when the caller did not provide a common
12951        // open-ended backfill that already covers the old delivery anchor.
12952        let mut replacement_backfills = Vec::new();
12953        for (owner, interests) in &owners {
12954            let previous_filters: Vec<Filter> = self
12955                .owner_interests(owner)
12956                .map(log_filters)
12957                .unwrap_or_default();
12958            let continuity_anchor = previous_filters
12959                .iter()
12960                .filter_map(|filter| self.log_anchor(filter))
12961                .min();
12962            let filters = log_filters(interests);
12963            if let Some(backfill) = explicit_backfill
12964                && !filters.is_empty()
12965            {
12966                replacement_backfills.push(QueuedSubscriberBackfill {
12967                    owner: Some(owner.clone()),
12968                    epoch: None,
12969                    filters: filters.clone(),
12970                    backfill,
12971                });
12972            }
12973            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
12974                explicit.end_block().is_none()
12975                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
12976            });
12977            let continuity_filters: Vec<_> = filters
12978                .into_iter()
12979                .filter(|filter| !previous_filters.contains(filter))
12980                .collect();
12981            if let Some(anchor) = continuity_anchor
12982                && !continuity_filters.is_empty()
12983                && !explicit_covers
12984            {
12985                replacement_backfills.push(QueuedSubscriberBackfill {
12986                    owner: Some(owner.clone()),
12987                    epoch: None,
12988                    filters: continuity_filters,
12989                    backfill: SubscriberBackfill::from_block(anchor),
12990                });
12991            }
12992        }
12993
12994        let retained_backfills = self
12995            .pending_backfills
12996            .iter()
12997            .filter(|queued| {
12998                queued
12999                    .owner
13000                    .as_ref()
13001                    .is_none_or(|owner| !seen.contains(owner))
13002            })
13003            .map(|queued| queued.filters.len())
13004            .sum::<usize>();
13005        let replacement_units = replacement_backfills
13006            .iter()
13007            .map(|queued| queued.filters.len())
13008            .sum::<usize>();
13009        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13010        {
13011            return Err(SubscriberError::ResourceExhausted(format!(
13012                "bulk owner update would queue more than {} lazy backfills",
13013                self.config.max_pending_backfills
13014            )));
13015        }
13016
13017        // All validation and capacity checks are complete. The remaining
13018        // assignments have no failure or cancellation point, so topology and
13019        // historical work become authoritative as one local commit.
13020        self.owned_interests = next_owned;
13021        self.interests = next_registered;
13022        for owner in &seen {
13023            self.recent_compat_owner_input_refs.remove(owner);
13024            self.recent_compat_owner_input_ref_sets.remove(owner);
13025        }
13026        self.retire_unreferenced_filters();
13027        self.sources_dirty = true;
13028        self.pending_backfills.retain(|queued| {
13029            queued
13030                .owner
13031                .as_ref()
13032                .is_none_or(|owner| !seen.contains(owner))
13033        });
13034        self.pending_backfills.extend(replacement_backfills);
13035        Ok(())
13036    }
13037
13038    /// Atomically replace every compatibility owner without requesting
13039    /// historical delivery.
13040    ///
13041    /// # Errors
13042    ///
13043    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13044    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
13045    /// The previous topology remains authoritative on error.
13046    pub fn replace_interest_owners(
13047        &mut self,
13048        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13049    ) -> Result<(), SubscriberError> {
13050        self.replace_interest_owners_inner(owners, None)
13051    }
13052
13053    /// Atomically replace every compatibility owner and queue one global
13054    /// post-baseline backfill for the resulting union of log interests.
13055    ///
13056    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
13057    /// be mixed with this compatibility replacement because silently deleting
13058    /// an in-flight epoch would violate its activation transaction.
13059    ///
13060    /// # Errors
13061    ///
13062    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13063    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13064    /// exhaustion. The previous topology remains authoritative on error.
13065    pub fn replace_interest_owners_with_global_backfill(
13066        &mut self,
13067        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13068        backfill: SubscriberBackfill,
13069    ) -> Result<(), SubscriberError> {
13070        self.replace_interest_owners_inner(owners, Some(backfill))
13071    }
13072
13073    fn replace_interest_owners_inner(
13074        &mut self,
13075        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13076        backfill: Option<SubscriberBackfill>,
13077    ) -> Result<(), SubscriberError> {
13078        validate_subscriber_config(&self.config)?;
13079        if self
13080            .owned_interests
13081            .iter()
13082            .any(|entry| entry.epoch.is_some())
13083        {
13084            return Err(SubscriberError::InvalidConfig(
13085                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
13086            ));
13087        }
13088
13089        let mut seen = HashSet::with_capacity(owners.len());
13090        let mut next_owned = Vec::with_capacity(owners.len());
13091        for (owner, interests) in owners {
13092            if !seen.insert(owner.clone()) {
13093                return Err(SubscriberError::InvalidConfig(
13094                    "owner replacement contains a duplicate owner",
13095                ));
13096            }
13097            next_owned.push(OwnedSubscriberInterests {
13098                owner,
13099                interests,
13100                epoch: None,
13101                state: SubscriberOwnerState::Active,
13102                baseline: None,
13103                progress: None,
13104                progress_stream_revision: None,
13105            });
13106        }
13107        let next_registered = aggregate_interests(&[], &next_owned);
13108        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13109        let mut filters = log_filters(&next_registered);
13110        let mut unique_filters = Vec::with_capacity(filters.len());
13111        for filter in filters.drain(..) {
13112            if !unique_filters.contains(&filter) {
13113                unique_filters.push(filter);
13114            }
13115        }
13116        let replacement_backfills: VecDeque<_> = match backfill {
13117            Some(backfill) if !unique_filters.is_empty() => {
13118                VecDeque::from([QueuedSubscriberBackfill {
13119                    owner: None,
13120                    epoch: None,
13121                    filters: unique_filters,
13122                    backfill,
13123                }])
13124            }
13125            Some(_) | None => VecDeque::new(),
13126        };
13127        let replacement_units = replacement_backfills
13128            .iter()
13129            .map(|queued| queued.filters.len())
13130            .sum::<usize>();
13131        if replacement_units > self.config.max_pending_backfills {
13132            return Err(SubscriberError::ResourceExhausted(format!(
13133                "owner replacement would queue more than {} lazy backfills",
13134                self.config.max_pending_backfills
13135            )));
13136        }
13137
13138        // No fallible work remains. The post-baseline range reconstructs every
13139        // delivery after the cache snapshot, so reset all stale delivery and
13140        // dedupe state from the prior topology before publishing the exact
13141        // replacement plus its global historical work.
13142        self.base_interests.clear();
13143        self.owned_interests = next_owned;
13144        self.interests = next_registered;
13145        self.reset_delivery_state();
13146        self.pending_backfills = replacement_backfills;
13147        self.state = AlloySubscriberState::Uninitialized;
13148        Ok(())
13149    }
13150
13151    /// Add or replace the interests owned by `owner`.
13152    ///
13153    /// This preserves unrelated owners, queued/pending records, recent dedupe
13154    /// state, and last-seen log anchors. The live transport is reconciled on the
13155    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
13156    /// be subscribed without rebuilding the whole subscriber object.
13157    ///
13158    /// Replacing an existing owner is continuity-safe: filters the owner
13159    /// already had keep their delivery anchors, and any changed or new filter
13160    /// shape is automatically backfilled from the owner's oldest prior anchor —
13161    /// growing a pool set on an established owner does not open a delivery gap
13162    /// for what the old subscription had already covered. A brand-new owner has
13163    /// no anchor to inherit; pass an explicit
13164    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
13165    /// anchor (or register through [`ReactiveEngine::register_handler`], which
13166    /// anchors to the runtime's last canonical block).
13167    ///
13168    /// # Errors
13169    ///
13170    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13171    /// lifecycle state, unsupported interests, or continuity-backfill capacity
13172    /// exhaustion. The prior owner state remains authoritative on error.
13173    pub fn add_interest_owner(
13174        &mut self,
13175        owner: HandlerId,
13176        interests: &[ReactiveInterest<N>],
13177    ) -> Result<(), SubscriberError> {
13178        self.set_interest_owner(owner, interests, None)
13179    }
13180
13181    /// Add or replace owner interests and schedule log backfill for that owner.
13182    ///
13183    /// Backfill is queued only for log interests; block and pending transaction
13184    /// interests are live-only. Queued records can be delivered immediately;
13185    /// the subsequent provider stream is then caught up from the seeded
13186    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
13187    /// is closed end to end as long
13188    /// as `backfill` starts at (or before) the block the interest was
13189    /// discovered in. Continuity backfill for a replaced owner (see
13190    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
13191    /// unless this explicit backfill is open-ended and already starts at or
13192    /// below the owner's prior anchor.
13193    ///
13194    /// # Errors
13195    ///
13196    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13197    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
13198    /// The prior owner state remains authoritative on error.
13199    pub fn add_interest_owner_with_backfill(
13200        &mut self,
13201        owner: HandlerId,
13202        interests: &[ReactiveInterest<N>],
13203        backfill: SubscriberBackfill,
13204    ) -> Result<(), SubscriberError> {
13205        self.set_interest_owner(owner, interests, Some(backfill))
13206    }
13207
13208    /// Add or replace one owner at retained canonical block `C`, then queue the
13209    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
13210    ///
13211    /// The new owner alone receives matching records from `C` so its effects
13212    /// attach to the runtime's existing journal entry. Every matching log from
13213    /// `C + 1` through the activation head is then delivered canonically over
13214    /// the complete interest union. [`Self::next_scoped_batch`] installs the
13215    /// desired live streams before draining either window, closing the
13216    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
13217    /// pending-transaction deliveries through this log backfill path, so a
13218    /// mixed interest topology is rejected rather than silently underfilled.
13219    ///
13220    /// # Errors
13221    ///
13222    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13223    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
13224    /// resource exhaustion. The prior owner state remains authoritative on
13225    /// error.
13226    pub fn add_interest_owner_with_canonical_catchup(
13227        &mut self,
13228        owner: HandlerId,
13229        interests: &[ReactiveInterest<N>],
13230        retained: BlockRef,
13231    ) -> Result<(), SubscriberError> {
13232        validate_subscriber_config(&self.config)?;
13233        if self
13234            .owned_interests
13235            .iter()
13236            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13237        {
13238            return Err(SubscriberError::InvalidConfig(
13239                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13240            ));
13241        }
13242
13243        let mut next_owned = self.clone_owned_interests();
13244        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
13245            entry.interests = interests.to_vec();
13246            entry.state = SubscriberOwnerState::Active;
13247            entry.baseline = None;
13248            entry.progress = None;
13249            entry.progress_stream_revision = None;
13250            entry.epoch = None;
13251        } else {
13252            next_owned.push(OwnedSubscriberInterests {
13253                owner: owner.clone(),
13254                interests: interests.to_vec(),
13255                epoch: None,
13256                state: SubscriberOwnerState::Active,
13257                baseline: None,
13258                progress: None,
13259                progress_stream_revision: None,
13260            });
13261        }
13262        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13263        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13264        if next_registered
13265            .iter()
13266            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13267        {
13268            return Err(SubscriberError::Unsupported(
13269                "Alloy coordinated registration supports log-only interest topologies",
13270            ));
13271        }
13272
13273        let mut owner_filters = Vec::new();
13274        for filter in log_filters(interests) {
13275            if !owner_filters.contains(&filter) {
13276                owner_filters.push(filter);
13277            }
13278        }
13279        let mut global_filters = Vec::new();
13280        for filter in log_filters(&next_registered) {
13281            if !global_filters.contains(&filter) {
13282                global_filters.push(filter);
13283            }
13284        }
13285        let owner_backfill =
13286            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
13287        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
13288        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
13289        let retained_units = self
13290            .pending_backfills
13291            .iter()
13292            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13293            .map(|queued| queued.filters.len())
13294            .sum::<usize>();
13295        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
13296            return Err(SubscriberError::ResourceExhausted(format!(
13297                "coordinated owner registration would queue more than {} lazy backfills",
13298                self.config.max_pending_backfills
13299            )));
13300        }
13301
13302        let mut replacement_backfills = VecDeque::new();
13303        if !owner_filters.is_empty() {
13304            replacement_backfills.push_back(QueuedSubscriberBackfill {
13305                owner: Some(owner.clone()),
13306                epoch: None,
13307                filters: owner_filters,
13308                backfill: owner_backfill,
13309            });
13310        }
13311        // Keep the global certification job even for an empty filter union: it
13312        // advances canonical coverage through a zero-event registration window.
13313        replacement_backfills.push_back(QueuedSubscriberBackfill {
13314            owner: None,
13315            epoch: None,
13316            filters: global_filters,
13317            backfill: global_backfill,
13318        });
13319
13320        // Every fallible preflight is complete. Publish topology and both
13321        // ordered windows as one synchronous local commit.
13322        self.owned_interests = next_owned;
13323        self.interests = next_registered;
13324        self.recent_compat_owner_input_refs.remove(&owner);
13325        self.recent_compat_owner_input_ref_sets.remove(&owner);
13326        self.pending_backfills
13327            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13328        self.pending_backfills.extend(replacement_backfills);
13329        self.retire_unreferenced_filters();
13330        self.sources_dirty = true;
13331        Ok(())
13332    }
13333
13334    /// Remove one owner's interests, preserving unrelated owner/base interests.
13335    ///
13336    /// The owner's queued backfills are dropped, and source-id/anchor
13337    /// bookkeeping for filters no other owner references is retired. Live
13338    /// streams for retired filters are torn down on the next
13339    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
13340    /// unsubscribes provider-side); events already in flight from them stop
13341    /// matching the merged interest set and are discarded.
13342    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
13343        let index = self
13344            .owned_interests
13345            .iter()
13346            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
13347        let removed = self.owned_interests.remove(index);
13348        if let Some(epoch) = &removed.epoch {
13349            self.purge_owner_epoch(epoch);
13350        } else {
13351            self.pending_backfills
13352                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
13353            self.recent_compat_owner_input_refs.remove(owner);
13354            self.recent_compat_owner_input_ref_sets.remove(owner);
13355        }
13356        self.rebuild_registered_interests();
13357        self.retire_unreferenced_filters();
13358        self.sources_dirty = true;
13359        Some(removed.interests)
13360    }
13361
13362    /// Borrow the interests currently owned by `owner`.
13363    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
13364        self.owned_interests
13365            .iter()
13366            .find(|entry| &entry.owner == owner)
13367            .map(|entry| entry.interests.as_slice())
13368    }
13369
13370    fn set_interest_owner(
13371        &mut self,
13372        owner: HandlerId,
13373        interests: &[ReactiveInterest<N>],
13374        backfill: Option<SubscriberBackfill>,
13375    ) -> Result<(), SubscriberError> {
13376        validate_subscriber_config(&self.config)?;
13377        if self
13378            .owned_interests
13379            .iter()
13380            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13381        {
13382            return Err(SubscriberError::InvalidConfig(
13383                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13384            ));
13385        }
13386
13387        let mut next_owned = self.clone_owned_interests();
13388        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
13389            Some(entry) => {
13390                entry.interests = interests.to_vec();
13391                entry.state = SubscriberOwnerState::Active;
13392                entry.baseline = None;
13393                entry.progress = None;
13394                entry.progress_stream_revision = None;
13395                entry.epoch.take()
13396            }
13397            None => {
13398                next_owned.push(OwnedSubscriberInterests {
13399                    owner: owner.clone(),
13400                    interests: interests.to_vec(),
13401                    epoch: None,
13402                    state: SubscriberOwnerState::Active,
13403                    baseline: None,
13404                    progress: None,
13405                    progress_stream_revision: None,
13406                });
13407                None
13408            }
13409        };
13410        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13411        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13412
13413        // Continuity capture, before the mutation lands: the owner's previous
13414        // filter shapes and the oldest delivery anchor among them. A changed
13415        // filter gets a fresh source id with no anchor, so without this
13416        // hand-off, replacing an owner's interests (the normal way to grow a
13417        // pool set) would silently discard the delivery watermark and open a
13418        // gap until some later explicit backfill.
13419        let previous_filters: Vec<Filter> = self
13420            .owner_interests(&owner)
13421            .map(log_filters)
13422            .unwrap_or_default();
13423        let continuity_anchor: Option<u64> = previous_filters
13424            .iter()
13425            .filter_map(|filter| self.log_anchor(filter))
13426            .min();
13427
13428        // Build the replacement queue before committing owner state. Capacity
13429        // failure is therefore atomic and cannot leave desired interests ahead
13430        // of the historical work required to make them continuous.
13431        let mut replacement_backfills = Vec::new();
13432        let filters = log_filters(interests);
13433        if let Some(backfill) = backfill
13434            && !filters.is_empty()
13435        {
13436            replacement_backfills.push(QueuedSubscriberBackfill {
13437                owner: Some(owner.clone()),
13438                epoch: None,
13439                filters: filters.clone(),
13440                backfill,
13441            });
13442        }
13443        let explicit_covers = backfill.is_some_and(|explicit| {
13444            explicit.end_block().is_none()
13445                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
13446        });
13447        let continuity_filters: Vec<_> = filters
13448            .into_iter()
13449            .filter(|filter| !previous_filters.contains(filter))
13450            .collect();
13451        if let Some(anchor) = continuity_anchor
13452            && !continuity_filters.is_empty()
13453            && !explicit_covers
13454        {
13455            replacement_backfills.push(QueuedSubscriberBackfill {
13456                owner: Some(owner.clone()),
13457                epoch: None,
13458                filters: continuity_filters,
13459                backfill: SubscriberBackfill::from_block(anchor),
13460            });
13461        }
13462        let retained_backfills = self
13463            .pending_backfills
13464            .iter()
13465            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13466            .map(|queued| queued.filters.len())
13467            .sum::<usize>();
13468        let replacement_units = replacement_backfills
13469            .iter()
13470            .map(|queued| queued.filters.len())
13471            .sum::<usize>();
13472        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13473        {
13474            return Err(SubscriberError::ResourceExhausted(format!(
13475                "owner update would queue more than {} lazy backfills",
13476                self.config.max_pending_backfills
13477            )));
13478        }
13479
13480        self.owned_interests = next_owned;
13481        self.interests = next_registered;
13482        if let Some(epoch) = replaced_epoch {
13483            self.purge_owner_epoch(&epoch);
13484        } else {
13485            self.recent_compat_owner_input_refs.remove(&owner);
13486            self.recent_compat_owner_input_ref_sets.remove(&owner);
13487        }
13488        self.retire_unreferenced_filters();
13489        self.sources_dirty = true;
13490
13491        // Re-queue this owner's backfills from scratch: previously queued
13492        // entries may reference filter shapes that no longer exist.
13493        self.pending_backfills
13494            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13495        self.pending_backfills.extend(replacement_backfills);
13496        Ok(())
13497    }
13498
13499    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
13500        self.owned_interests
13501            .iter()
13502            .map(|entry| OwnedSubscriberInterests {
13503                owner: entry.owner.clone(),
13504                interests: entry.interests.clone(),
13505                epoch: entry.epoch.clone(),
13506                state: entry.state,
13507                baseline: entry.baseline,
13508                progress: entry.progress.clone(),
13509                progress_stream_revision: entry.progress_stream_revision,
13510            })
13511            .collect()
13512    }
13513
13514    fn rebuild_registered_interests(&mut self) {
13515        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
13516    }
13517
13518    /// Delivery anchor (last block known fully delivered) for `filter`, if the
13519    /// filter has a source id and has seen delivery.
13520    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
13521        if let Some(anchor) = self
13522            .log_source_ids
13523            .get(filter)
13524            .and_then(|id| self.last_seen_log_blocks.get(id))
13525        {
13526            return Some(*anchor);
13527        }
13528
13529        // Logical owner filters may be represented by a broader provider
13530        // stream after fan-in. Its oldest live watermark is a conservative
13531        // continuity anchor: it can cause extra backfill, never a missed log.
13532        self.log_source_ids
13533            .values()
13534            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
13535            .min()
13536    }
13537
13538    /// Every logical log filter across base and owner interests, merged within
13539    /// each origin and deduplicated across origins. These shapes remain the
13540    /// exact routing and owner-continuity boundary; provider subscriptions may
13541    /// fan several of them into one broader filter.
13542    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13543    // `mutable_key_type` lint is a known false positive for it.
13544    #[allow(clippy::mutable_key_type)]
13545    fn logical_log_filters(&self) -> Vec<Filter> {
13546        let mut filters = log_filters(&self.base_interests);
13547        for entry in &self.owned_interests {
13548            filters.extend(log_filters(&entry.interests));
13549        }
13550        let mut seen = HashSet::new();
13551        filters.retain(|filter| seen.insert(filter.clone()));
13552        filters
13553    }
13554
13555    /// Provider-facing log filters. Compatible logical filters fan into a
13556    /// small number of address/topic supersets, then split only when the
13557    /// configured address ceiling requires it. Exact matching remains local in
13558    /// `enqueue_event`, so this reduces subscriptions without broadening owner
13559    /// delivery.
13560    fn log_stream_filters(&self) -> Vec<Filter> {
13561        let mut merged = Vec::new();
13562        for filter in self.logical_log_filters() {
13563            merge_log_subscription_filter(&mut merged, &filter);
13564        }
13565
13566        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
13567        let mut planned = Vec::new();
13568        for filter in merged {
13569            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
13570            if addresses.len() <= max_addresses {
13571                planned.push(filter);
13572                continue;
13573            }
13574            addresses.sort_unstable();
13575            for chunk in addresses.chunks(max_addresses) {
13576                let mut split = filter.clone();
13577                split.address = FilterSet::default();
13578                for address in chunk {
13579                    split.address.insert(*address);
13580                }
13581                planned.push(split);
13582            }
13583        }
13584        planned
13585    }
13586
13587    /// Drop source-id and anchor bookkeeping for filters no longer referenced
13588    /// by any base or owner interest, so long-lived owner churn cannot grow the
13589    /// maps unboundedly. Live streams for retired filters are pruned by the
13590    /// next reconcile.
13591    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13592    // `mutable_key_type` lint is a known false positive for it.
13593    #[allow(clippy::mutable_key_type)]
13594    fn retire_unreferenced_filters(&mut self) {
13595        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
13596        if let AlloySubscriberState::Active(streams) = &self.state {
13597            for entry in &streams.entries {
13598                match &entry.source {
13599                    SubscriberStreamSource::PubSubLog { filter, .. }
13600                    | SubscriberStreamSource::BasePendingLog { filter, .. }
13601                    | SubscriberStreamSource::PollingLog { filter } => {
13602                        live.insert(filter.clone());
13603                    }
13604                    SubscriberStreamSource::BaseFlashblocks
13605                    | SubscriberStreamSource::OpPendingFlashblocks
13606                    | SubscriberStreamSource::CanonicalHeadPolling
13607                    | SubscriberStreamSource::PubSubPendingHashes
13608                    | SubscriberStreamSource::PubSubBlockHeaders
13609                    | SubscriberStreamSource::PollingPendingHashes => {}
13610                }
13611            }
13612        }
13613        self.log_source_ids
13614            .retain(|filter, _| live.contains(filter));
13615        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
13616        self.last_seen_log_blocks
13617            .retain(|id, _| live_ids.contains(id));
13618    }
13619
13620    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
13621        if self.pending_records.is_empty()
13622            && self.pending_chain_controls.is_empty()
13623            && !self.pending_preconfirmation_invalidation
13624        {
13625            return None;
13626        }
13627
13628        let first_preconfirmation = self.pending_records.front().and_then(|record| {
13629            if record.scope != SubscriberInputScope::Preconfirmed {
13630                return None;
13631            }
13632            match &record.record.context.chain_status {
13633                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
13634                _ => None,
13635            }
13636        });
13637        let len = self
13638            .pending_records
13639            .iter()
13640            .take(self.config.max_batch_size)
13641            .take_while(|record| match &first_preconfirmation {
13642                Some(expected) => {
13643                    record.scope == SubscriberInputScope::Preconfirmed
13644                        && matches!(
13645                            &record.record.context.chain_status,
13646                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
13647                        )
13648                }
13649                None => record.scope != SubscriberInputScope::Preconfirmed,
13650            })
13651            .count();
13652        let records = self.pending_records.drain(..len).collect();
13653        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
13654            self.pending_chain_controls.drain(..).collect()
13655        } else {
13656            Vec::new()
13657        };
13658        Some(SubscriberInputBatch {
13659            records,
13660            chain_id: self.chain_id,
13661            chain_controls,
13662            preconfirmation_invalidated: std::mem::take(
13663                &mut self.pending_preconfirmation_invalidation,
13664            ),
13665        })
13666    }
13667
13668    fn reset_delivery_state(&mut self) {
13669        self.pending_records.clear();
13670        self.pending_chain_controls.clear();
13671        self.pending_reconcile_owner_records.clear();
13672        self.resource_error = None;
13673        self.last_seen_log_blocks.clear();
13674        self.verified_log_blocks.clear();
13675        self.verified_log_block_order.clear();
13676        self.recent_input_refs.clear();
13677        self.recent_input_ref_set.clear();
13678        self.recent_owner_input_refs.clear();
13679        self.recent_owner_input_ref_sets.clear();
13680        self.recent_compat_owner_input_refs.clear();
13681        self.recent_compat_owner_input_ref_sets.clear();
13682        self.pending_backfills.clear();
13683        self.pending_source_backfills.clear();
13684        self.pending_preconfirmation_invalidation = false;
13685        self.pending_flashblock_reconnects.clear();
13686        self.pending_flashblock_reconnect_sources.clear();
13687        self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
13688        self.log_source_ids.clear();
13689        self.next_log_source_id = 0;
13690        self.sources_dirty = true;
13691        self.last_certified_canonical_head = None;
13692        self.reset_flashblock_tracking();
13693    }
13694
13695    fn reset_flashblock_tracking(&mut self) {
13696        self.base_flashblock_header = None;
13697        self.base_flashblock_transactions = None;
13698        self.unmatched_pending_logs.clear();
13699        self.latest_preconfirmation = None;
13700        self.preconfirmed_seen_logs.clear();
13701        self.preconfirmed_receipted_transactions.clear();
13702        self.preconfirmed_unavailable_receipts.clear();
13703        self.consecutive_flashblock_poll_failures = 0;
13704    }
13705
13706    /// Revoke only the active speculative snapshot while keeping the pinned
13707    /// provider session and its streams alive. A sampled OP pending view can
13708    /// legitimately be replaced, or a provider backend can briefly return an
13709    /// older cumulative view. Either observation makes the current signing
13710    /// authority unsafe, but does not prove that the transport generation is
13711    /// broken and should be reconnected.
13712    fn invalidate_preconfirmation_snapshot(&mut self) {
13713        self.pending_records
13714            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
13715        self.pending_preconfirmation_invalidation = true;
13716        self.latest_preconfirmation = None;
13717        self.preconfirmed_seen_logs.clear();
13718        self.preconfirmed_receipted_transactions.clear();
13719        self.preconfirmed_unavailable_receipts.clear();
13720    }
13721
13722    fn bump_stream_revision(&mut self) {
13723        self.stream_revision = self.stream_revision.saturating_add(1);
13724    }
13725}
13726
13727impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
13728where
13729    P: Provider<N> + Send + Sync,
13730    N: Network + 'static,
13731    N::HeaderResponse: Send + 'static,
13732{
13733    fn upsert_interest_owners(
13734        &mut self,
13735        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13736    ) -> SubscriberOperation<'_, ()> {
13737        Box::pin(async move {
13738            if !owners.is_empty() {
13739                self.ensure_chain_id().await?;
13740            }
13741            AlloySubscriber::upsert_interest_owners(self, owners)
13742        })
13743    }
13744
13745    fn replace_interest_owners(
13746        &mut self,
13747        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13748    ) -> SubscriberOperation<'_, ()> {
13749        Box::pin(async move {
13750            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
13751                self.ensure_chain_id().await?;
13752            }
13753            AlloySubscriber::replace_interest_owners(self, owners)
13754        })
13755    }
13756
13757    fn replace_interest_owners_with_global_backfill(
13758        &mut self,
13759        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13760        backfill: SubscriberBackfill,
13761    ) -> SubscriberOperation<'_, ()> {
13762        Box::pin(async move {
13763            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
13764                self.ensure_chain_id().await?;
13765            }
13766            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
13767        })
13768    }
13769
13770    fn add_interest_owner(
13771        &mut self,
13772        owner: HandlerId,
13773        interests: &[ReactiveInterest<N>],
13774    ) -> SubscriberOperation<'_, ()> {
13775        let interests = interests.to_vec();
13776        Box::pin(async move {
13777            if !interests.is_empty() {
13778                self.ensure_chain_id().await?;
13779            }
13780            AlloySubscriber::add_interest_owner(self, owner, &interests)
13781        })
13782    }
13783
13784    fn add_interest_owner_with_backfill(
13785        &mut self,
13786        owner: HandlerId,
13787        interests: &[ReactiveInterest<N>],
13788        backfill: SubscriberBackfill,
13789    ) -> SubscriberOperation<'_, ()> {
13790        let interests = interests.to_vec();
13791        Box::pin(async move {
13792            if !interests.is_empty() {
13793                self.ensure_chain_id().await?;
13794            }
13795            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
13796        })
13797    }
13798
13799    fn add_interest_owner_with_canonical_catchup(
13800        &mut self,
13801        owner: HandlerId,
13802        interests: &[ReactiveInterest<N>],
13803        retained: BlockRef,
13804    ) -> SubscriberOperation<'_, ()> {
13805        let interests = interests.to_vec();
13806        Box::pin(async move {
13807            // Resolve provider identity before the synchronous topology commit;
13808            // cancellation or failure at this await leaves prior state intact.
13809            self.ensure_chain_id().await?;
13810            AlloySubscriber::add_interest_owner_with_canonical_catchup(
13811                self, owner, &interests, retained,
13812            )
13813        })
13814    }
13815
13816    fn remove_interest_owner(
13817        &mut self,
13818        owner: &HandlerId,
13819    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
13820        let owner = owner.clone();
13821        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
13822    }
13823
13824    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
13825        AlloySubscriber::owner_interests(self, owner)
13826    }
13827}
13828
13829enum AlloySubscriberState<N: Network> {
13830    Uninitialized,
13831    Active(SubscriberStreams<N>),
13832    Empty,
13833}
13834
13835struct SubscriberStreams<N: Network> {
13836    entries: Vec<SubscriberStreamEntry<N>>,
13837    next_index: usize,
13838}
13839
13840struct SubscriberStreamEntry<N: Network> {
13841    source: SubscriberStreamSource,
13842    stream: BoxStream<'static, SubscriberEvent<N>>,
13843}
13844
13845impl<N: Network> SubscriberStreams<N> {
13846    fn new() -> Self {
13847        Self {
13848            entries: Vec::new(),
13849            next_index: 0,
13850        }
13851    }
13852
13853    fn is_empty(&self) -> bool {
13854        self.entries.is_empty()
13855    }
13856
13857    fn push(
13858        &mut self,
13859        source: SubscriberStreamSource,
13860        stream: BoxStream<'static, SubscriberEvent<N>>,
13861    ) {
13862        self.entries.push(SubscriberStreamEntry { source, stream });
13863    }
13864
13865    #[cfg(test)]
13866    fn len(&self) -> usize {
13867        self.entries.len()
13868    }
13869
13870    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
13871        self.entries
13872            .iter()
13873            .any(|entry| entry.source.same_key(source))
13874    }
13875
13876    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
13877        self.entries
13878            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
13879        self.normalize_next_index();
13880    }
13881
13882    fn normalize_next_index(&mut self) {
13883        if self.entries.is_empty() {
13884            self.next_index = 0;
13885        } else if self.next_index >= self.entries.len() {
13886            self.next_index %= self.entries.len();
13887        }
13888    }
13889
13890    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
13891        poll_fn(|cx| {
13892            self.normalize_next_index();
13893            if self.entries.is_empty() {
13894                return std::task::Poll::Ready(None);
13895            }
13896
13897            let mut index = self.next_index;
13898            let mut checked = 0usize;
13899            while checked < self.entries.len() {
13900                if index >= self.entries.len() {
13901                    index = 0;
13902                }
13903                match self.entries[index].stream.as_mut().poll_next(cx) {
13904                    std::task::Poll::Ready(Some(event)) => {
13905                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
13906                            self.entries.remove(index);
13907                            self.next_index = if self.entries.is_empty() {
13908                                0
13909                            } else {
13910                                index % self.entries.len()
13911                            };
13912                        } else {
13913                            self.next_index = (index + 1) % self.entries.len();
13914                        }
13915                        return std::task::Poll::Ready(Some(event));
13916                    }
13917                    std::task::Poll::Ready(None) => {
13918                        self.entries.remove(index);
13919                        if self.entries.is_empty() {
13920                            self.next_index = 0;
13921                            return std::task::Poll::Ready(None);
13922                        }
13923                    }
13924                    std::task::Poll::Pending => {
13925                        checked += 1;
13926                        index += 1;
13927                    }
13928                }
13929            }
13930
13931            if self.entries.is_empty() {
13932                std::task::Poll::Ready(None)
13933            } else {
13934                self.next_index = index % self.entries.len();
13935                std::task::Poll::Pending
13936            }
13937        })
13938        .await
13939    }
13940}
13941
13942#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13943#[allow(dead_code)]
13944enum SubscriberTransport {
13945    PubSub,
13946    Polling,
13947}
13948
13949#[derive(Clone, Debug)]
13950enum SubscriberStreamSource {
13951    PubSubLog { id: usize, filter: Filter },
13952    BasePendingLog { id: usize, filter: Filter },
13953    BaseFlashblocks,
13954    OpPendingFlashblocks,
13955    CanonicalHeadPolling,
13956    PubSubPendingHashes,
13957    PubSubBlockHeaders,
13958    PollingLog { filter: Filter },
13959    PollingPendingHashes,
13960}
13961
13962impl SubscriberStreamSource {
13963    fn label(&self) -> &'static str {
13964        match self {
13965            Self::PubSubLog { .. } => "pubsub log",
13966            Self::BasePendingLog { .. } => "OP Stack pendingLogs",
13967            Self::BaseFlashblocks => "OP Stack newFlashblocks",
13968            Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
13969            Self::CanonicalHeadPolling => "certified canonical head",
13970            Self::PubSubPendingHashes => "pubsub pending transaction hash",
13971            Self::PubSubBlockHeaders => "pubsub block header",
13972            Self::PollingLog { .. } => "polling log",
13973            Self::PollingPendingHashes => "polling pending transaction hash",
13974        }
13975    }
13976
13977    fn is_pubsub(&self) -> bool {
13978        matches!(
13979            self,
13980            Self::PubSubLog { .. }
13981                | Self::BasePendingLog { .. }
13982                | Self::BaseFlashblocks
13983                | Self::OpPendingFlashblocks
13984                | Self::PubSubPendingHashes
13985                | Self::PubSubBlockHeaders
13986        )
13987    }
13988
13989    fn is_flashblocks(&self) -> bool {
13990        matches!(
13991            self,
13992            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
13993        )
13994    }
13995
13996    fn same_key(&self, other: &Self) -> bool {
13997        match (self, other) {
13998            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
13999            | (
14000                Self::BasePendingLog { filter: left, .. },
14001                Self::BasePendingLog { filter: right, .. },
14002            )
14003            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
14004                left == right
14005            }
14006            (Self::BaseFlashblocks, Self::BaseFlashblocks)
14007            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
14008            | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
14009            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
14010            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
14011            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
14012            _ => false,
14013        }
14014    }
14015}
14016
14017#[allow(dead_code)]
14018enum SubscriberEvent<N: Network> {
14019    Log {
14020        source_id: usize,
14021        log: Log,
14022    },
14023    BackfilledLogs {
14024        source_id: usize,
14025        logs: Vec<Log>,
14026    },
14027    Logs(Vec<Log>),
14028    BlockHeader(N::HeaderResponse),
14029    PendingHash(B256),
14030    PendingHashes(Vec<B256>),
14031    BasePendingLog {
14032        source_id: usize,
14033        log: Log,
14034    },
14035    BaseFlashblock(BaseFlashblockWirePayload),
14036    OpFlashblockTick,
14037    CanonicalHeadTick,
14038    PreconfirmedLogs {
14039        flashblock: FlashblockRef,
14040        logs: Vec<Log>,
14041    },
14042    FlashblockInvalidated,
14043    FlashblockObserved,
14044    StreamTerminated(SubscriberStreamSource),
14045}
14046
14047enum SubscriberReady<N: Network> {
14048    Event(Option<SubscriberEvent<N>>),
14049    FlashblockReconnect(
14050        SubscriberStreamSource,
14051        Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
14052    ),
14053}
14054
14055#[derive(Debug)]
14056enum PendingFlashblockPollError {
14057    Request(SubscriberError),
14058    Integrity(SubscriberError),
14059}
14060
14061impl PendingFlashblockPollError {
14062    fn into_subscriber(self) -> SubscriberError {
14063        match self {
14064            Self::Request(error) | Self::Integrity(error) => error,
14065        }
14066    }
14067}
14068
14069fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
14070    PendingFlashblockPollError::Request(provider_error(error))
14071}
14072
14073fn normalize_op_pending_block<N: Network>(
14074    mut value: serde_json::Value,
14075) -> Result<N::BlockResponse, SubscriberError> {
14076    let object = value.as_object_mut().ok_or_else(|| {
14077        SubscriberError::Provider("OP pending block response is not an object".into())
14078    })?;
14079    let transactions = object
14080        .get_mut("transactions")
14081        .and_then(serde_json::Value::as_array_mut)
14082        .ok_or_else(|| {
14083            SubscriberError::Provider(
14084                "OP pending block response is missing its transaction array".into(),
14085            )
14086        })?;
14087    for transaction in transactions {
14088        if transaction.is_string() {
14089            continue;
14090        }
14091        let hash = transaction
14092            .as_object()
14093            .and_then(|object| object.get("hash"))
14094            .filter(|hash| hash.is_string())
14095            .cloned()
14096            .ok_or_else(|| {
14097                SubscriberError::Provider("OP pending block transaction is missing its hash".into())
14098            })?;
14099        *transaction = hash;
14100    }
14101    if object.get("hash").is_none_or(serde_json::Value::is_null) {
14102        object.insert(
14103            "hash".into(),
14104            serde_json::Value::String(B256::ZERO.to_string()),
14105        );
14106    }
14107    if object.get("nonce").is_none_or(serde_json::Value::is_null) {
14108        object.insert(
14109            "nonce".into(),
14110            serde_json::Value::String("0x0000000000000000".into()),
14111        );
14112    }
14113    if object.get("miner").is_none_or(serde_json::Value::is_null)
14114        || object
14115            .get("beneficiary")
14116            .is_none_or(serde_json::Value::is_null)
14117    {
14118        object.insert(
14119            "miner".into(),
14120            serde_json::Value::String(Address::ZERO.to_string()),
14121        );
14122    }
14123    serde_json::from_value(value).map_err(|error| {
14124        SubscriberError::Provider(format!(
14125            "failed to decode normalized OP pending block: {error}"
14126        ))
14127    })
14128}
14129
14130fn normalize_pending_transaction_receipt(
14131    expected_transaction_hash: B256,
14132    value: serde_json::Value,
14133) -> Result<Option<Vec<Log>>, SubscriberError> {
14134    if value.is_null() {
14135        return Ok(None);
14136    }
14137    let receipt = value.as_object().ok_or_else(|| {
14138        SubscriberError::Provider("pending transaction receipt response is not an object".into())
14139    })?;
14140    let transaction_hash: B256 =
14141        serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
14142            SubscriberError::Provider(
14143                "pending transaction receipt is missing its transaction hash".into(),
14144            )
14145        })?)
14146        .map_err(|error| {
14147            SubscriberError::Provider(format!(
14148                "failed to decode pending transaction receipt hash: {error}"
14149            ))
14150        })?;
14151    if transaction_hash != expected_transaction_hash {
14152        return Err(SubscriberError::Provider(
14153            "pending transaction receipt hash disagrees with its request".into(),
14154        ));
14155    }
14156    let receipt_logs = receipt
14157        .get("logs")
14158        .and_then(serde_json::Value::as_array)
14159        .ok_or_else(|| {
14160            SubscriberError::Provider("pending transaction receipt is missing its log array".into())
14161        })?;
14162    let mut logs = Vec::new();
14163    for log in receipt_logs {
14164        let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
14165            SubscriberError::Provider(format!(
14166                "failed to decode pending transaction receipt log: {error}"
14167            ))
14168        })?;
14169        if log.transaction_hash != Some(expected_transaction_hash) {
14170            return Err(SubscriberError::Provider(
14171                "pending transaction receipt log hash disagrees with its receipt".into(),
14172            ));
14173        }
14174        logs.push(log);
14175    }
14176    Ok(Some(logs))
14177}
14178
14179impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
14180where
14181    P: Provider<N> + Send + Sync,
14182    N: Network + 'static,
14183    N::HeaderResponse: Send + 'static,
14184{
14185    fn chain_id(&self) -> Option<u64> {
14186        self.chain_id
14187    }
14188
14189    fn capabilities(&self) -> SubscriberCapabilities {
14190        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
14191            return SubscriberCapabilities::default();
14192        };
14193        let mut capabilities = vec![
14194            SubscriberCapability::Logs,
14195            SubscriberCapability::PendingTransactionHashes,
14196            SubscriberCapability::HistoricalBackfill,
14197            SubscriberCapability::Live,
14198            SubscriberCapability::OwnerScopedDelivery,
14199            SubscriberCapability::DynamicInterests,
14200        ];
14201        if transport == SubscriberTransport::PubSub {
14202            capabilities.push(SubscriberCapability::BlockHeaders);
14203        }
14204        if self.config.preconfirmations != PreconfirmationMode::Disabled
14205            && self.provider_ref.is_some()
14206            && self.chain_id.and_then(flashblocks_adapter).is_some()
14207        {
14208            capabilities.push(SubscriberCapability::Preconfirmations);
14209        }
14210        SubscriberCapabilities::new(capabilities)
14211    }
14212
14213    fn register_interests(
14214        &mut self,
14215        interests: &[ReactiveInterest<N>],
14216    ) -> SubscriberOperation<'_, ()> {
14217        let interests = interests.to_vec();
14218        Box::pin(async move {
14219            validate_subscriber_config(&self.config)?;
14220            validate_supported_interests(self.mode, &self.config, &interests)?;
14221            if !interests.is_empty() {
14222                self.ensure_chain_id().await?;
14223            }
14224            self.validate_flashblocks_setup()?;
14225
14226            self.base_interests = interests;
14227            self.owned_interests.clear();
14228            self.rebuild_registered_interests();
14229            self.reset_delivery_state();
14230            self.state = AlloySubscriberState::Uninitialized;
14231            Ok(())
14232        })
14233    }
14234
14235    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
14236        Box::pin(async {
14237            Ok(self
14238                .next_scoped_batch()
14239                .await?
14240                .map(SubscriberInputBatch::into_reactive_batch))
14241        })
14242    }
14243}
14244
14245impl<P, N> AlloySubscriber<P, N>
14246where
14247    P: Provider<N> + Send + Sync,
14248    N: Network + 'static,
14249    N::HeaderResponse: Send + 'static,
14250{
14251    /// Validate one pinned OP Stack provider generation and establish its
14252    /// chain-specific Flashblocks surface.
14253    ///
14254    /// The caller must register at least one active log interest first. The
14255    /// method requires a matching chain id and stable [`ProviderRef`]. Base
14256    /// additionally requires pubsub, `newFlashblocks`, and one `pendingLogs`
14257    /// acknowledgement per planned provider filter. Optimism probes the
14258    /// bounded pending block/log/receipt surface. `op_supportedCapabilities`
14259    /// is queried opportunistically and retained as opaque evidence because
14260    /// provider implementations do not expose a uniform capability vocabulary.
14261    ///
14262    /// A successful return is deliberately not a liveness qualification. The
14263    /// acceptance window must still observe a Flashblock whose pending state
14264    /// advances and a correlated log for an active pool.
14265    pub async fn establish_flashblocks_preflight(
14266        &mut self,
14267        expected_chain_id: u64,
14268    ) -> Result<FlashblocksPreflight, SubscriberError> {
14269        validate_subscriber_config(&self.config)?;
14270        if self.config.preconfirmations == PreconfirmationMode::Disabled {
14271            return Err(SubscriberError::InvalidConfig(
14272                "Flashblocks preflight requires preconfirmations",
14273            ));
14274        }
14275        if !self
14276            .interests
14277            .iter()
14278            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
14279        {
14280            return Err(SubscriberError::InvalidConfig(
14281                "Flashblocks preflight requires at least one active log interest",
14282            ));
14283        }
14284        let chain_id = self.ensure_chain_id().await?;
14285        if chain_id != expected_chain_id {
14286            return Err(SubscriberError::ChainMismatch {
14287                expected: expected_chain_id,
14288                actual: chain_id,
14289            });
14290        }
14291        self.validate_flashblocks_setup()?;
14292        let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
14293            "Flashblocks are currently implemented for Base and OP chains",
14294        ))?;
14295        let provider = self
14296            .provider_ref
14297            .clone()
14298            .ok_or(SubscriberError::InvalidConfig(
14299                "Flashblocks preflight requires a stable provider ref",
14300            ))?;
14301        self.flashblocks_rpc_metrics.capability_requests = self
14302            .flashblocks_rpc_metrics
14303            .capability_requests
14304            .saturating_add(1);
14305        let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
14306            self.flashblocks_state_provider
14307                .as_ref()
14308                .unwrap_or(&self.provider)
14309        } else {
14310            &self.provider
14311        };
14312        let advertised_capabilities = capability_provider
14313            .client()
14314            .request::<_, serde_json::Value>("op_supportedCapabilities", ())
14315            .await
14316            .ok();
14317
14318        self.ensure_streams().await?;
14319        let pending_log_filters = self.log_stream_filters();
14320        if adapter == FlashblocksAdapter::PendingStatePolling
14321            && self.pending_receipt_requests_per_tick_capacity() == 0
14322        {
14323            return Err(SubscriberError::InvalidConfig(
14324                "Flashblocks RPC budget leaves no capacity for OP transaction receipts",
14325            ));
14326        }
14327        let (delivery, pending_log_subscriptions) = match adapter {
14328            FlashblocksAdapter::NativeSubscriptions => {
14329                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
14330                    return Err(SubscriberError::Unsupported(
14331                        "Base Flashblocks preflight requires pubsub",
14332                    ));
14333                }
14334                let pending_sources = self
14335                    .pubsub_stream_sources()
14336                    .into_iter()
14337                    .filter(|source| {
14338                        matches!(source, SubscriberStreamSource::BasePendingLog { .. })
14339                    })
14340                    .collect::<Vec<_>>();
14341                let AlloySubscriberState::Active(streams) = &self.state else {
14342                    return Err(SubscriberError::Provider(
14343                        "Flashblocks preflight subscriptions did not become active".to_owned(),
14344                    ));
14345                };
14346                if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
14347                    || pending_sources
14348                        .iter()
14349                        .any(|source| !streams.contains_source(source))
14350                {
14351                    return Err(SubscriberError::Provider(
14352                        "Base Flashblocks preflight did not retain both subscription lanes"
14353                            .to_owned(),
14354                    ));
14355                }
14356                (
14357                    FlashblocksDelivery::NativeSubscriptions,
14358                    pending_sources.len(),
14359                )
14360            }
14361            FlashblocksAdapter::PendingStatePolling => {
14362                if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
14363                    self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
14364                        .flashblocks_rpc_metrics
14365                        .provider_pair_chain_requests
14366                        .saturating_add(1);
14367                    let actual = state_provider
14368                        .get_chain_id()
14369                        .await
14370                        .map_err(provider_error)?;
14371                    if actual != expected_chain_id {
14372                        return Err(SubscriberError::ChainMismatch {
14373                            expected: expected_chain_id,
14374                            actual,
14375                        });
14376                    }
14377                }
14378                let AlloySubscriberState::Active(streams) = &self.state else {
14379                    return Err(SubscriberError::Provider(
14380                        "Flashblocks preflight streams did not become active".to_owned(),
14381                    ));
14382                };
14383                if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
14384                    return Err(SubscriberError::Provider(
14385                        "Optimism Flashblocks preflight did not retain its pending-state sampler"
14386                            .to_owned(),
14387                    ));
14388                }
14389                self.probe_pending_state(&pending_log_filters).await?;
14390                (FlashblocksDelivery::PendingStatePolling, 0)
14391            }
14392        };
14393        Ok(FlashblocksPreflight {
14394            chain_id,
14395            provider,
14396            delivery,
14397            pending_log_subscriptions,
14398            pending_log_filters: pending_log_filters.len(),
14399            advertised_capabilities,
14400        })
14401    }
14402
14403    async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
14404        self.flashblocks_rpc_metrics.pending_block_requests = self
14405            .flashblocks_rpc_metrics
14406            .pending_block_requests
14407            .saturating_add(1);
14408        let pending = self
14409            .fetch_op_pending_block()
14410            .await
14411            .map_err(PendingFlashblockPollError::into_subscriber)?
14412            .ok_or_else(|| {
14413                SubscriberError::Provider(
14414                    "provider returned no pending block during Flashblocks preflight".into(),
14415                )
14416            })?;
14417        self.certify_op_pending_parent(&pending)
14418            .await
14419            .map_err(PendingFlashblockPollError::into_subscriber)?;
14420        for filter in filters {
14421            self.flashblocks_rpc_metrics.pending_log_requests = self
14422                .flashblocks_rpc_metrics
14423                .pending_log_requests
14424                .saturating_add(1);
14425            self.flashblocks_state_provider
14426                .as_ref()
14427                .unwrap_or(&self.provider)
14428                .get_logs(
14429                    &filter
14430                        .clone()
14431                        .from_block(BlockNumberOrTag::Latest)
14432                        .to_block(BlockNumberOrTag::Pending),
14433                )
14434                .await
14435                .map_err(provider_error)?;
14436        }
14437        self.flashblocks_rpc_metrics.pending_receipt_requests = self
14438            .flashblocks_rpc_metrics
14439            .pending_receipt_requests
14440            .saturating_add(1);
14441        let _: serde_json::Value = self
14442            .flashblocks_state_provider
14443            .as_ref()
14444            .unwrap_or(&self.provider)
14445            .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
14446            .await
14447            .map_err(provider_error)?;
14448        Ok(())
14449    }
14450
14451    async fn certify_op_pending_parent(
14452        &mut self,
14453        pending: &N::BlockResponse,
14454    ) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
14455        let pending_header = pending.header();
14456        let pending_number = pending_header.number();
14457        let parent_hash = pending_header.parent_hash();
14458        if pending_number == 0 || parent_hash.is_zero() {
14459            return Err(PendingFlashblockPollError::Integrity(
14460                SubscriberError::Provider(
14461                    "OP pending block omitted a certifiable canonical parent".into(),
14462                ),
14463            ));
14464        }
14465        self.flashblocks_rpc_metrics.canonical_head_requests = self
14466            .flashblocks_rpc_metrics
14467            .canonical_head_requests
14468            .saturating_add(1);
14469        let parent = self
14470            .flashblocks_state_provider
14471            .as_ref()
14472            .unwrap_or(&self.provider)
14473            .get_block_by_hash(parent_hash)
14474            .await
14475            .map_err(pending_flashblock_request_error)?
14476            .ok_or_else(|| {
14477                PendingFlashblockPollError::Request(SubscriberError::Provider(
14478                    "Flashblocks provider returned no exact OP pending parent block".into(),
14479                ))
14480            })?;
14481        let parent_header = parent.header();
14482        if parent_header.hash() != parent_hash
14483            || parent_header.number().checked_add(1) != Some(pending_number)
14484        {
14485            return Err(PendingFlashblockPollError::Integrity(
14486                SubscriberError::Provider(
14487                    "OP pending block does not extend its exact certified parent".into(),
14488                ),
14489            ));
14490        }
14491        Ok(parent_header.clone())
14492    }
14493
14494    async fn fetch_op_pending_block(
14495        &mut self,
14496    ) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
14497        let state_provider = self
14498            .flashblocks_state_provider
14499            .as_ref()
14500            .unwrap_or(&self.provider);
14501        let value: Option<serde_json::Value> = state_provider
14502            .raw_request(
14503                Cow::Borrowed("eth_getBlockByNumber"),
14504                (BlockNumberOrTag::Pending, true),
14505            )
14506            .await
14507            .map_err(pending_flashblock_request_error)?;
14508        value
14509            .map(normalize_op_pending_block::<N>)
14510            .transpose()
14511            .map_err(PendingFlashblockPollError::Integrity)
14512    }
14513
14514    /// Resolve the provider's chain identity once. The assignment happens only
14515    /// after a complete RPC response, so cancelling the future leaves the
14516    /// subscriber cleanly retryable.
14517    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
14518        if let Some(chain_id) = self.chain_id {
14519            return Ok(chain_id);
14520        }
14521        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
14522        self.chain_id = Some(chain_id);
14523        Ok(chain_id)
14524    }
14525
14526    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
14527        if self.config.preconfirmations == PreconfirmationMode::Disabled {
14528            return Ok(());
14529        }
14530        if self.provider_ref.is_none() {
14531            return Err(SubscriberError::InvalidConfig(
14532                "Flashblocks require a stable provider ref from a pinned provider lease",
14533            ));
14534        }
14535        let Some(chain_id) = self.chain_id else {
14536            return Ok(());
14537        };
14538        match flashblocks_adapter(chain_id) {
14539            Some(FlashblocksAdapter::NativeSubscriptions)
14540                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
14541                    && self.config.preconfirmations == PreconfirmationMode::Required =>
14542            {
14543                return Err(SubscriberError::Unsupported(
14544                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
14545                ));
14546            }
14547            Some(FlashblocksAdapter::NativeSubscriptions) => {}
14548            Some(_) => {}
14549            None if self.config.preconfirmations == PreconfirmationMode::Required => {
14550                return Err(SubscriberError::Unsupported(
14551                    "Flashblocks are currently implemented for Base and OP chains",
14552                ));
14553            }
14554            None => {}
14555        }
14556        Ok(())
14557    }
14558
14559    /// Subscribe first, then catch an exact staged owner up through a verified
14560    /// canonical block.
14561    ///
14562    /// This compatibility wrapper delegates to
14563    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
14564    /// driver adopting several owners should call the bulk API once rather than
14565    /// invoking this method in a loop.
14566    ///
14567    /// # Errors
14568    ///
14569    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
14570    /// baseline, conflicts/regresses, provider certification or transport
14571    /// fails, returned logs are invalid, or subscriber resources are exhausted.
14572    pub async fn reconcile_interest_owner(
14573        &mut self,
14574        epoch: &SubscriberOwnerEpoch,
14575        through: BlockRef,
14576    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
14577    where
14578        P: Clone,
14579    {
14580        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
14581            .await?
14582            .pop()
14583            .ok_or(SubscriberOwnerError::NotStaged)
14584    }
14585
14586    /// Subscribe first, then atomically catch staged owners up through one
14587    /// verified canonical block.
14588    ///
14589    /// All epochs are preflighted before provider I/O. Live streams are
14590    /// reconciled once, compatible provider filters are merged into bounded
14591    /// chunks, and every historical request shares one double target-header
14592    /// certification. Provider-filter supersets are routed back through each
14593    /// owner's exact interests, retaining owner-scoped delivery provenance.
14594    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
14595    ///
14596    /// Live events are continuously drained while an independent provider
14597    /// clone performs catch-up. Fetched owner records and progress become
14598    /// visible only after every request and the final certification succeed. A
14599    /// failure leaves every target staged with its prior progress unchanged;
14600    /// live canonical delivery consumed during the attempt is preserved while
14601    /// excluding the failed target epochs from its staged-owner audience.
14602    ///
14603    /// # Errors
14604    ///
14605    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
14606    /// baseline, conflicts/regresses, provider certification or transport
14607    /// fails, returned logs are invalid, or subscriber resources are exhausted.
14608    /// Target progress remains unchanged on error.
14609    pub async fn reconcile_interest_owners(
14610        &mut self,
14611        epochs: &[SubscriberOwnerEpoch],
14612        through: BlockRef,
14613    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
14614    where
14615        P: Clone,
14616    {
14617        if epochs.is_empty() {
14618            return Ok(Vec::new());
14619        }
14620
14621        self.ensure_chain_id().await?;
14622
14623        let mut seen = HashSet::new();
14624        let mut plans = Vec::with_capacity(epochs.len());
14625        for epoch in epochs {
14626            if !seen.insert(epoch.clone()) {
14627                continue;
14628            }
14629            let entry = self
14630                .owned_interests
14631                .iter()
14632                .find(|entry| {
14633                    entry.epoch.as_ref() == Some(epoch)
14634                        && entry.state == SubscriberOwnerState::Staged
14635                })
14636                .ok_or(SubscriberOwnerError::NotStaged)?;
14637            let position = entry
14638                .progress
14639                .as_ref()
14640                .map(|progress| &progress.through)
14641                .or(entry.baseline.as_ref())
14642                .ok_or(SubscriberOwnerError::MissingBaseline)?;
14643            let baseline = position.number;
14644            if through.number < baseline {
14645                return Err(SubscriberOwnerError::ProgressRegression {
14646                    current: baseline,
14647                    target: through.number,
14648                });
14649            }
14650            let from_block = baseline
14651                .checked_add(1)
14652                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
14653            if through.number == baseline && through.hash != position.hash {
14654                return Err(SubscriberOwnerError::ProgressConflict {
14655                    number: baseline,
14656                    current_hash: position.hash,
14657                    target_hash: through.hash,
14658                });
14659            }
14660            if through.number == from_block
14661                && through
14662                    .parent_hash
14663                    .is_some_and(|parent| parent != position.hash)
14664            {
14665                return Err(SubscriberOwnerError::ProgressConflict {
14666                    number: baseline,
14667                    current_hash: position.hash,
14668                    target_hash: through.parent_hash.expect("checked as present above"),
14669                });
14670            }
14671            if entry
14672                .interests
14673                .iter()
14674                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
14675            {
14676                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
14677            }
14678            plans.push(SubscriberOwnerReconcilePlan {
14679                epoch: epoch.clone(),
14680                interests: entry.interests.clone(),
14681                retained: *position,
14682                from_block,
14683            });
14684        }
14685
14686        // The ordering is intentional and part of the public continuity
14687        // contract: connect first, then fetch the bounded historical window.
14688        self.ensure_streams().await?;
14689        let provider = self.provider.clone();
14690        let filters = merged_owner_reconcile_filters(&plans, through.number);
14691        let retained = plans.iter().map(|plan| plan.retained).collect();
14692        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
14693        let fetch = fetch_owner_catchup::<P, N>(
14694            provider,
14695            filters,
14696            retained,
14697            through,
14698            SubscriberOwnerCatchupOptions {
14699                target_preverified: false,
14700                max_logs: self.config.max_pending_records,
14701                max_log_bytes: self.config.max_backfill_log_bytes,
14702                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
14703            },
14704        );
14705        let SubscriberOwnerCatchup { logs, certified } =
14706            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
14707
14708        let records = logs
14709            .into_iter()
14710            .map(|log| log_input_record(log, InputSource::Backfill))
14711            .collect();
14712        let mut routed_records = Vec::new();
14713        for record in dedupe_records(sort_records(records)).map_err(|error| {
14714            SubscriberError::InvalidBackfill(format!(
14715                "conflicting duplicate owner catch-up record: {error}"
14716            ))
14717        })? {
14718            let block_number = match &record.input {
14719                ReactiveInput::Log(log) => log
14720                    .block_number
14721                    .expect("bulk catch-up logs were validated before commit"),
14722                _ => unreachable!("bulk owner catch-up contains log records only"),
14723            };
14724            let owners: Vec<SubscriberOwnerEpoch> = plans
14725                .iter()
14726                .filter(|plan| block_number >= plan.from_block)
14727                .filter(|plan| {
14728                    plan.interests
14729                        .iter()
14730                        .any(|interest| interest_matches(interest, &record.input))
14731                })
14732                .map(|plan| plan.epoch.clone())
14733                .collect();
14734            if !owners.is_empty() {
14735                routed_records.push((record, owners));
14736            }
14737        }
14738        self.ensure_pending_record_capacity(
14739            routed_records.len(),
14740            "owner reconciliation historical records",
14741        )?;
14742
14743        // Nothing provider-derived becomes authoritative until every record is
14744        // known to fit. In particular, preserve queued retry state and owner
14745        // progress when the bounded delivery queue cannot accept the catch-up.
14746        self.pending_backfills.retain(|queued| {
14747            queued
14748                .epoch
14749                .as_ref()
14750                .is_none_or(|epoch| !target_epochs.contains(epoch))
14751        });
14752        for (record, owners) in routed_records {
14753            self.enqueue_owner_record_for_owners_unmerged(record, owners);
14754        }
14755        self.promote_reconcile_owner_records(&target_epochs);
14756        self.seed_reconciled_filter_anchors(&plans, certified.number);
14757
14758        let stream_revision = self.stream_revision;
14759        let mut progress = Vec::with_capacity(plans.len());
14760        for plan in plans {
14761            let item = SubscriberOwnerProgress {
14762                owner: plan.epoch.clone(),
14763                through: certified,
14764            };
14765            let entry = self
14766                .owned_interests
14767                .iter_mut()
14768                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
14769                .expect("bulk reconcile holds exclusive access after epoch preflight");
14770            entry.progress = Some(item.clone());
14771            entry.progress_stream_revision = Some(stream_revision);
14772            progress.push(item);
14773        }
14774        Ok(progress)
14775    }
14776
14777    async fn drive_reconcile_fetch<T, F>(
14778        &mut self,
14779        fetch: F,
14780        target_epochs: &HashSet<SubscriberOwnerEpoch>,
14781    ) -> Result<T, SubscriberOwnerError>
14782    where
14783        F: Future<Output = Result<T, SubscriberOwnerError>>,
14784    {
14785        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
14786            return fetch.await;
14787        }
14788        let mut fetch = Box::pin(fetch);
14789        loop {
14790            let event = {
14791                let live = Box::pin(self.next_event());
14792                match select(fetch, live).await {
14793                    Either::Left((result, pending_live)) => {
14794                        drop(pending_live);
14795                        return result;
14796                    }
14797                    Either::Right((event, pending_fetch)) => {
14798                        fetch = pending_fetch;
14799                        event
14800                    }
14801                }
14802            };
14803            let event = event?.ok_or_else(|| {
14804                SubscriberError::Provider(
14805                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
14806                )
14807            })?;
14808            self.buffer_reconcile_event_for_owners(&event, target_epochs);
14809            self.enqueue_event_excluding_owners(event, target_epochs);
14810            self.check_resource_error()?;
14811        }
14812    }
14813
14814    /// Poll one driver control future with priority over the next scoped batch.
14815    ///
14816    /// This is the supported control-interleaving primitive for a subscriber
14817    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
14818    /// the caller's pending control future alive. When control wins, the
14819    /// in-progress subscriber poll is cancelled at a documented safe boundary:
14820    /// queued records are removed only when a complete batch is returned,
14821    /// successful backfill steps are committed before the next await, provider
14822    /// streams created but not installed are dropped, and installed streams
14823    /// remain owned by the subscriber for the next call.
14824    ///
14825    /// The control future is polled first. Therefore a ready shutdown/removal
14826    /// command cannot starve behind a continuously ready subscriber queue.
14827    ///
14828    /// # Errors
14829    ///
14830    /// Returns [`SubscriberError`] when the subscriber poll encounters a
14831    /// transport, continuity, decoding, configuration, or resource failure.
14832    pub async fn next_scoped_batch_or<C, F>(
14833        &mut self,
14834        control: Pin<&mut F>,
14835    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
14836    where
14837        C: Send,
14838        F: Future<Output = C> + Send,
14839    {
14840        let batch = self.next_scoped_batch();
14841        match select(control, batch).await {
14842            Either::Left((control, pending_batch)) => {
14843                drop(pending_batch);
14844                Ok(SubscriberDriverPoll::Control(control))
14845            }
14846            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
14847        }
14848    }
14849
14850    /// Return the next subscriber batch while retaining staged-owner delivery
14851    /// provenance captured at enqueue time.
14852    ///
14853    /// Transaction-aware drivers must use this method. The compatibility
14854    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
14855    /// its historical behavior for existing callers.
14856    ///
14857    /// For command interleaving, prefer
14858    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
14859    /// cancellation-safety invariants of this poll and prioritizes ready control.
14860    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
14861        Box::pin(async {
14862            self.check_resource_error()?;
14863            if self.chain_id.is_none()
14864                && (!self.pending_records.is_empty()
14865                    || !self.pending_chain_controls.is_empty()
14866                    || !self.pending_backfills.is_empty()
14867                    || !self.interests.is_empty())
14868            {
14869                self.ensure_chain_id().await?;
14870            }
14871            if let Some(batch) = self.drain_next_scoped_batch() {
14872                return Ok(Some(batch));
14873            }
14874
14875            // Subscribe/adopt the complete desired topology before resolving
14876            // any queued historical upper bound. Live streams therefore own
14877            // every event that can arrive while the bounded backfill is in
14878            // flight, including the coordinated registration window.
14879            self.ensure_streams().await?;
14880            self.check_resource_error()?;
14881            if let Some(batch) = self.drain_next_scoped_batch() {
14882                return Ok(Some(batch));
14883            }
14884
14885            self.drain_pending_backfills().await?;
14886            self.check_resource_error()?;
14887            if let Some(batch) = self.drain_next_scoped_batch() {
14888                return Ok(Some(batch));
14889            }
14890
14891            if self.interests.is_empty() {
14892                return Ok(None);
14893            }
14894
14895            loop {
14896                let Some(event) = self.next_event().await? else {
14897                    return Ok(None);
14898                };
14899
14900                self.enqueue_event(event);
14901                self.check_resource_error()?;
14902                if let Some(batch) = self.drain_next_scoped_batch() {
14903                    return Ok(Some(batch));
14904                }
14905            }
14906        })
14907    }
14908
14909    /// Bring live streams in line with the current interest set.
14910    ///
14911    /// Runs incrementally: the desired-vs-live diff only happens when interest
14912    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
14913    /// steady-state polling costs nothing here. Missing sources are connected,
14914    /// sources for retired filters are dropped (dropping an Alloy subscription
14915    /// unsubscribes provider-side), and unrelated live streams — with their
14916    /// delivery and anchor state — are left untouched.
14917    ///
14918    /// A newly connected log source whose filter already has a delivery anchor
14919    /// is caught up from that anchor immediately after subscribing (the same
14920    /// subscribe-then-backfill order the reconnect path uses). Together with
14921    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
14922    /// window between an adoption backfill and live stream start.
14923    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
14924        if !self.sources_dirty {
14925            return Ok(());
14926        }
14927        // An interest-less subscriber never touches the provider
14928        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
14929        // the empty desired topology as clean so a deliberately empty staged
14930        // epoch can reconcile and activate instead of remaining dirty forever.
14931        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
14932            self.bump_stream_revision();
14933            self.sources_dirty = false;
14934            return Ok(());
14935        }
14936
14937        let desired = self.stream_sources()?;
14938        let missing: Vec<SubscriberStreamSource> = match &self.state {
14939            AlloySubscriberState::Active(streams) => desired
14940                .iter()
14941                .filter(|source| !streams.contains_source(source))
14942                .cloned()
14943                .collect(),
14944            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
14945        };
14946
14947        for source in missing {
14948            let stream = match self.connect_source_stream(source.clone()).await {
14949                Ok(stream) => stream,
14950                Err(error)
14951                    if source.is_flashblocks()
14952                        && self.config.preconfirmations == PreconfirmationMode::Preferred =>
14953                {
14954                    tracing::warn!(
14955                        stream = source.label(),
14956                        error = %error,
14957                        "Flashblocks source unavailable; canonical delivery remains active"
14958                    );
14959                    if self.config.reconnect.enabled {
14960                        self.schedule_flashblock_reconnect(
14961                            source,
14962                            self.config.reconnect.retry_delay,
14963                        );
14964                    }
14965                    continue;
14966                }
14967                Err(error) => return Err(error),
14968            };
14969            // Publish each successful connection before any later await. If a
14970            // second connection or anchored catch-up fails/cancels, this stream
14971            // remains live and the next reconcile skips reconnecting it.
14972            self.install_source_stream(source.clone(), stream);
14973            if self.source_requires_backfill(&source) {
14974                self.queue_source_backfill(source);
14975            }
14976        }
14977
14978        while let Some(source) = self.pending_source_backfills.front().cloned() {
14979            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
14980                && matches!(
14981                    &self.state,
14982                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
14983                );
14984            if !desired_and_live {
14985                self.pending_source_backfills.pop_front();
14986                continue;
14987            }
14988
14989            // Anchored catch-up for a source with a known delivery watermark
14990            // (seeded by a drained adoption backfill, or inherited from a
14991            // filter shape that was live before): subscribe first, then fetch
14992            // the gap, so nothing lands between the two. Pop only after the
14993            // request succeeds; errors and cancellation retain retry intent.
14994            let event = self.backfill_reconnected_source(&source).await?;
14995            self.pending_source_backfills.pop_front();
14996            if let Some(event) = event {
14997                self.enqueue_event(event);
14998            }
14999        }
15000
15001        if let AlloySubscriberState::Active(streams) = &mut self.state {
15002            streams.retain_sources(&desired);
15003            if streams.is_empty() {
15004                self.state = AlloySubscriberState::Empty;
15005            }
15006        }
15007
15008        self.bump_stream_revision();
15009        self.sources_dirty = false;
15010        self.retire_unreferenced_filters();
15011        Ok(())
15012    }
15013
15014    fn install_source_stream(
15015        &mut self,
15016        source: SubscriberStreamSource,
15017        stream: BoxStream<'static, SubscriberEvent<N>>,
15018    ) {
15019        match &mut self.state {
15020            AlloySubscriberState::Active(streams) => {
15021                if streams.contains_source(&source) {
15022                    return;
15023                }
15024                streams.push(source, stream);
15025            }
15026            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
15027                let mut streams = SubscriberStreams::new();
15028                streams.push(source, stream);
15029                self.state = AlloySubscriberState::Active(streams);
15030            }
15031        }
15032        // A partially completed reconcile is still a topology change. Advance
15033        // the revision now rather than only at the final clean boundary.
15034        self.bump_stream_revision();
15035    }
15036
15037    fn schedule_flashblock_reconnect(
15038        &mut self,
15039        source: SubscriberStreamSource,
15040        first_delay: Duration,
15041    ) {
15042        if self
15043            .pending_flashblock_reconnect_sources
15044            .iter()
15045            .any(|pending| pending.same_key(&source))
15046        {
15047            return;
15048        }
15049        self.pending_flashblock_reconnect_sources
15050            .push(source.clone());
15051        self.pending_flashblock_reconnects
15052            .push(flashblock_reconnect_future(
15053                self.provider.root().clone(),
15054                source,
15055                self.config.max_batch_size,
15056                self.config.reconnect.clone(),
15057                first_delay,
15058                self.config.flashblock_poll_interval,
15059            ));
15060    }
15061
15062    fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
15063        if !self.config.reconnect.enabled {
15064            return;
15065        }
15066        self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
15067    }
15068
15069    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
15070        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
15071            if self.last_seen_log_blocks.contains_key(id))
15072    }
15073
15074    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
15075        if !self
15076            .pending_source_backfills
15077            .iter()
15078            .any(|pending| pending.same_key(&source))
15079        {
15080            self.pending_source_backfills.push_back(source);
15081        }
15082    }
15083
15084    /// Fetch queued adoption/continuity backfills, oldest first.
15085    ///
15086    /// An entry is consumed only after its `get_logs` fetch succeeds — a
15087    /// transient RPC failure surfaces the error and leaves the entry queued for
15088    /// the next poll, so a flaky request cannot silently discard the missed
15089    /// window the backfill exists to close. Open-ended backfills resolve their
15090    /// upper bound to the provider's current head before fetching, and every
15091    /// drained backfill advances the filter's delivery anchor to that bound —
15092    /// even a zero-log window — so the filter is reconnect-protected from then
15093    /// on. Draining pauses as soon as records are ready for delivery; remaining
15094    /// entries stay queued.
15095    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
15096        while let Some(queued) = self.pending_backfills.front() {
15097            // Owner was removed while its backfill was queued.
15098            let epoch = queued.epoch.clone();
15099            let owner = queued.owner.clone();
15100            let owner_exists = match (&epoch, &owner) {
15101                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
15102                (None, Some(owner)) => self.owner_interests(owner).is_some(),
15103                (None, None) => true,
15104            };
15105            if !owner_exists {
15106                self.pending_backfills.pop_front();
15107                continue;
15108            }
15109            let filters = queued.filters.clone();
15110            let backfill = queued.backfill;
15111
15112            let to_block = match backfill.end_block() {
15113                Some(to_block) => to_block,
15114                None => self
15115                    .provider
15116                    .get_block_number()
15117                    .await
15118                    .map_err(provider_error)?,
15119            };
15120            if to_block < backfill.start_block() {
15121                // An exclusive post-baseline range can be empty when the
15122                // provider is still exactly at the retained head. Consume the
15123                // work only after validating that head and seed the filter at
15124                // the proven baseline so reconnect catch-up starts at C + 1.
15125                let certified = if let Some(retained) = backfill.retained_anchor() {
15126                    let actual =
15127                        fetch_provider_block_ref::<P, N>(&self.provider, retained.number).await?;
15128                    if !block_ref_satisfies_expected(&actual, retained) {
15129                        return Err(SubscriberError::InvalidBackfill(format!(
15130                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
15131                            retained.number, retained.hash, actual.number, actual.hash
15132                        )));
15133                    }
15134                    if to_block < retained.number {
15135                        return Err(SubscriberError::InvalidBackfill(format!(
15136                            "backfill upper bound {to_block} precedes retained anchor {}",
15137                            retained.number
15138                        )));
15139                    }
15140                    Some(actual)
15141                } else {
15142                    None
15143                };
15144                self.pending_backfills.pop_front();
15145                for filter in &filters {
15146                    let source_id = self.log_source_id(filter);
15147                    if let Some(certified) = certified {
15148                        self.last_seen_log_blocks
15149                            .entry(source_id)
15150                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
15151                            .or_insert(certified.number);
15152                    }
15153                }
15154                if owner.is_none()
15155                    && let Some(certified) = certified
15156                {
15157                    self.pending_chain_controls
15158                        .push_back(global_backfill_barrier(backfill, certified));
15159                }
15160                if !self.pending_chain_controls.is_empty() {
15161                    break;
15162                }
15163                continue;
15164            }
15165
15166            let through = fetch_provider_block_ref::<P, N>(&self.provider, to_block).await?;
15167            let request_filters =
15168                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
15169            let retained = backfill.retained_anchor().copied().into_iter().collect();
15170            let SubscriberOwnerCatchup {
15171                mut logs,
15172                certified,
15173            } = fetch_owner_catchup::<&P, N>(
15174                &self.provider,
15175                request_filters,
15176                retained,
15177                through,
15178                SubscriberOwnerCatchupOptions {
15179                    target_preverified: true,
15180                    max_logs: self.config.max_pending_records,
15181                    max_log_bytes: self.config.max_backfill_log_bytes,
15182                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
15183                },
15184            )
15185            .await
15186            .map_err(lazy_backfill_error)?;
15187            logs.sort_by_key(|log| {
15188                (
15189                    log.block_number.unwrap_or_default(),
15190                    log.transaction_index.unwrap_or_default(),
15191                    log.log_index.unwrap_or_default(),
15192                )
15193            });
15194            logs.dedup();
15195            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
15196
15197            // Fetch succeeded: consume the entry, deliver, and advance the
15198            // complete filter group through one globally ordered window.
15199            self.pending_backfills.pop_front();
15200            if let Some(epoch) = epoch.as_ref() {
15201                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
15202            } else if let Some(owner) = owner.as_ref() {
15203                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
15204            } else {
15205                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
15206                self.pending_chain_controls
15207                    .push_back(global_backfill_barrier(backfill, certified));
15208            }
15209            for filter in &filters {
15210                let source_id = self.log_source_id(filter);
15211                let anchor = self
15212                    .last_seen_log_blocks
15213                    .entry(source_id)
15214                    .or_insert(certified.number);
15215                *anchor = (*anchor).max(certified.number);
15216            }
15217
15218            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
15219                break;
15220            }
15221        }
15222        Ok(())
15223    }
15224
15225    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
15226        match resolve_subscriber_transport(self.mode)? {
15227            SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()),
15228            SubscriberTransport::Polling => Ok(self.polling_stream_sources()),
15229        }
15230    }
15231
15232    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
15233        let mut sources = Vec::new();
15234        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
15235
15236        for filter in self.log_stream_filters() {
15237            let id = self.log_source_id(&filter);
15238            if let Some(anchor) = inherited_anchor {
15239                self.last_seen_log_blocks.entry(id).or_insert(anchor);
15240            }
15241            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
15242        }
15243
15244        if needs_pending_hash_stream(&self.interests) {
15245            sources.push(SubscriberStreamSource::PubSubPendingHashes);
15246        }
15247
15248        if needs_header_block_stream(&self.interests) {
15249            if self.config.preconfirmations != PreconfirmationMode::Disabled
15250                && self.chain_id.and_then(flashblocks_adapter).is_some()
15251            {
15252                sources.push(SubscriberStreamSource::CanonicalHeadPolling);
15253            } else {
15254                sources.push(SubscriberStreamSource::PubSubBlockHeaders);
15255            }
15256        }
15257
15258        if self.config.preconfirmations != PreconfirmationMode::Disabled {
15259            match self.chain_id.and_then(flashblocks_adapter) {
15260                Some(FlashblocksAdapter::NativeSubscriptions) => {
15261                    sources.push(SubscriberStreamSource::BaseFlashblocks);
15262                    for filter in self.log_stream_filters() {
15263                        let id = self.log_source_id(&filter);
15264                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
15265                    }
15266                }
15267                Some(FlashblocksAdapter::PendingStatePolling) => {
15268                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15269                }
15270                None => {}
15271            }
15272        }
15273
15274        sources
15275    }
15276
15277    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
15278        let mut sources = Vec::new();
15279
15280        for filter in self.log_stream_filters() {
15281            sources.push(SubscriberStreamSource::PollingLog { filter });
15282        }
15283
15284        if needs_pending_hash_stream(&self.interests) {
15285            sources.push(SubscriberStreamSource::PollingPendingHashes);
15286        }
15287
15288        if self.config.preconfirmations != PreconfirmationMode::Disabled
15289            && self.chain_id.and_then(flashblocks_adapter)
15290                == Some(FlashblocksAdapter::PendingStatePolling)
15291        {
15292            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15293        }
15294
15295        sources
15296    }
15297
15298    fn log_source_id(&mut self, filter: &Filter) -> usize {
15299        if let Some(id) = self.log_source_ids.get(filter) {
15300            return *id;
15301        }
15302
15303        let id = self.next_log_source_id;
15304        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
15305        self.log_source_ids.insert(filter.clone(), id);
15306        id
15307    }
15308
15309    async fn connect_source_stream(
15310        &mut self,
15311        source: SubscriberStreamSource,
15312    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15313        match source {
15314            SubscriberStreamSource::PubSubLog { id, filter } => {
15315                self.connect_pubsub_log_stream(id, filter).await
15316            }
15317            SubscriberStreamSource::BasePendingLog { id, filter } => {
15318                self.connect_base_pending_log_stream(id, filter).await
15319            }
15320            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
15321            SubscriberStreamSource::OpPendingFlashblocks => {
15322                self.connect_op_flashblock_tick_stream()
15323            }
15324            SubscriberStreamSource::CanonicalHeadPolling => {
15325                self.connect_canonical_head_tick_stream()
15326            }
15327            SubscriberStreamSource::PubSubPendingHashes => {
15328                self.connect_pubsub_pending_hash_stream().await
15329            }
15330            SubscriberStreamSource::PubSubBlockHeaders => {
15331                self.connect_pubsub_block_header_stream().await
15332            }
15333            SubscriberStreamSource::PollingLog { filter } => {
15334                self.connect_polling_log_stream(filter).await
15335            }
15336            SubscriberStreamSource::PollingPendingHashes => {
15337                self.connect_polling_pending_hash_stream().await
15338            }
15339        }
15340    }
15341
15342    async fn connect_pubsub_log_stream(
15343        &mut self,
15344        id: usize,
15345        filter: Filter,
15346    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15347        #[cfg(feature = "reactive-ws")]
15348        {
15349            let source = SubscriberStreamSource::PubSubLog {
15350                id,
15351                filter: filter.clone(),
15352            };
15353            let stream = self
15354                .provider
15355                .subscribe_logs(&filter)
15356                .channel_size(self.config.max_batch_size.max(1))
15357                .await
15358                .map_err(provider_error)?
15359                .into_stream()
15360                .map(move |log| SubscriberEvent::Log { source_id: id, log });
15361            Ok(stream_with_termination(stream, source))
15362        }
15363
15364        #[cfg(not(feature = "reactive-ws"))]
15365        {
15366            let _ = (id, filter);
15367            Err(SubscriberError::Unsupported(
15368                "AlloySubscriber pubsub mode requires the reactive-ws feature",
15369            ))
15370        }
15371    }
15372
15373    async fn connect_base_pending_log_stream(
15374        &mut self,
15375        id: usize,
15376        filter: Filter,
15377    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15378        #[cfg(feature = "reactive-ws")]
15379        {
15380            let source = SubscriberStreamSource::BasePendingLog {
15381                id,
15382                filter: filter.clone(),
15383            };
15384            let params = base_pending_log_filter(&filter)?;
15385            let stream = self
15386                .provider
15387                .subscribe::<_, Log>(("pendingLogs", params))
15388                .channel_size(self.config.max_batch_size.max(1))
15389                .await
15390                .map_err(provider_error)?
15391                .into_stream()
15392                .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
15393            Ok(stream_with_termination(stream, source))
15394        }
15395
15396        #[cfg(not(feature = "reactive-ws"))]
15397        {
15398            let _ = (id, filter);
15399            Err(SubscriberError::Unsupported(
15400                "Base Flashblocks require the reactive-ws feature",
15401            ))
15402        }
15403    }
15404
15405    async fn connect_base_flashblock_stream(
15406        &mut self,
15407    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15408        #[cfg(feature = "reactive-ws")]
15409        {
15410            let stream = self
15411                .provider
15412                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
15413                .channel_size(self.config.max_batch_size.max(1))
15414                .await
15415                .map_err(provider_error)?
15416                .into_stream()
15417                .map(SubscriberEvent::BaseFlashblock);
15418            Ok(stream_with_termination(
15419                stream,
15420                SubscriberStreamSource::BaseFlashblocks,
15421            ))
15422        }
15423
15424        #[cfg(not(feature = "reactive-ws"))]
15425        {
15426            Err(SubscriberError::Unsupported(
15427                "Base Flashblocks require the reactive-ws feature",
15428            ))
15429        }
15430    }
15431
15432    fn connect_canonical_head_tick_stream(
15433        &self,
15434    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15435        let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
15436        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
15437        let stream = stream::unfold(interval, |mut interval| async move {
15438            interval.tick().await;
15439            Some((SubscriberEvent::CanonicalHeadTick, interval))
15440        });
15441        Ok(stream_with_termination(
15442            stream,
15443            SubscriberStreamSource::CanonicalHeadPolling,
15444        ))
15445    }
15446
15447    fn connect_op_flashblock_tick_stream(
15448        &self,
15449    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15450        let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
15451        let mut interval =
15452            tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
15453        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
15454        let stream = stream::unfold(interval, |mut interval| async move {
15455            interval.tick().await;
15456            Some((SubscriberEvent::OpFlashblockTick, interval))
15457        });
15458        Ok(stream_with_termination(
15459            stream,
15460            SubscriberStreamSource::OpPendingFlashblocks,
15461        ))
15462    }
15463
15464    async fn connect_pubsub_pending_hash_stream(
15465        &mut self,
15466    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15467        #[cfg(feature = "reactive-ws")]
15468        {
15469            let stream = self
15470                .provider
15471                .subscribe_pending_transactions()
15472                .channel_size(self.config.max_batch_size.max(1))
15473                .await
15474                .map_err(provider_error)?
15475                .into_stream()
15476                .map(SubscriberEvent::PendingHash);
15477            Ok(stream_with_termination(
15478                stream,
15479                SubscriberStreamSource::PubSubPendingHashes,
15480            ))
15481        }
15482
15483        #[cfg(not(feature = "reactive-ws"))]
15484        {
15485            Err(SubscriberError::Unsupported(
15486                "AlloySubscriber pubsub mode requires the reactive-ws feature",
15487            ))
15488        }
15489    }
15490
15491    async fn connect_pubsub_block_header_stream(
15492        &mut self,
15493    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15494        #[cfg(feature = "reactive-ws")]
15495        {
15496            let stream = self
15497                .provider
15498                .subscribe_blocks()
15499                .channel_size(self.config.max_batch_size.max(1))
15500                .await
15501                .map_err(provider_error)?
15502                .into_stream()
15503                .map(SubscriberEvent::BlockHeader);
15504            Ok(stream_with_termination(
15505                stream,
15506                SubscriberStreamSource::PubSubBlockHeaders,
15507            ))
15508        }
15509
15510        #[cfg(not(feature = "reactive-ws"))]
15511        {
15512            Err(SubscriberError::Unsupported(
15513                "AlloySubscriber pubsub mode requires the reactive-ws feature",
15514            ))
15515        }
15516    }
15517
15518    async fn connect_polling_log_stream(
15519        &mut self,
15520        filter: Filter,
15521    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15522        #[cfg(feature = "reactive-polling")]
15523        {
15524            let source = SubscriberStreamSource::PollingLog {
15525                filter: filter.clone(),
15526            };
15527            let stream = self
15528                .provider
15529                .watch_logs(&filter)
15530                .await
15531                .map_err(provider_error)?
15532                .with_channel_size(self.config.max_batch_size.max(1))
15533                .into_stream()
15534                .map(SubscriberEvent::Logs);
15535            Ok(stream_with_termination(stream, source))
15536        }
15537
15538        #[cfg(not(feature = "reactive-polling"))]
15539        {
15540            let _ = filter;
15541            Err(SubscriberError::Unsupported(
15542                "AlloySubscriber polling mode requires the reactive-polling feature",
15543            ))
15544        }
15545    }
15546
15547    async fn connect_polling_pending_hash_stream(
15548        &mut self,
15549    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15550        #[cfg(feature = "reactive-polling")]
15551        {
15552            let stream = self
15553                .provider
15554                .watch_pending_transactions()
15555                .await
15556                .map_err(provider_error)?
15557                .with_channel_size(self.config.max_batch_size.max(1))
15558                .into_stream()
15559                .map(SubscriberEvent::PendingHashes);
15560            Ok(stream_with_termination(
15561                stream,
15562                SubscriberStreamSource::PollingPendingHashes,
15563            ))
15564        }
15565
15566        #[cfg(not(feature = "reactive-polling"))]
15567        {
15568            Err(SubscriberError::Unsupported(
15569                "AlloySubscriber polling mode requires the reactive-polling feature",
15570            ))
15571        }
15572    }
15573
15574    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15575        loop {
15576            let ready = match &mut self.state {
15577                AlloySubscriberState::Active(streams)
15578                    if !self.pending_flashblock_reconnects.is_empty() =>
15579                {
15580                    let stream_event = Box::pin(streams.next());
15581                    let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
15582                    match select(reconnect, stream_event).await {
15583                        Either::Left((reconnect, pending_event)) => {
15584                            drop(pending_event);
15585                            let Some((source, result)) = reconnect else {
15586                                continue;
15587                            };
15588                            SubscriberReady::FlashblockReconnect(source, result)
15589                        }
15590                        Either::Right((event, pending_reconnect)) => {
15591                            drop(pending_reconnect);
15592                            SubscriberReady::Event(event)
15593                        }
15594                    }
15595                }
15596                AlloySubscriberState::Active(streams) => {
15597                    SubscriberReady::Event(streams.next().await)
15598                }
15599                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
15600                    if !self.pending_flashblock_reconnects.is_empty() =>
15601                {
15602                    let Some((source, result)) = self.pending_flashblock_reconnects.next().await
15603                    else {
15604                        continue;
15605                    };
15606                    SubscriberReady::FlashblockReconnect(source, result)
15607                }
15608                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
15609                    return Ok(None);
15610                }
15611            };
15612
15613            let event = match ready {
15614                SubscriberReady::Event(event) => event,
15615                SubscriberReady::FlashblockReconnect(source, result) => {
15616                    self.pending_flashblock_reconnect_sources
15617                        .retain(|pending| !pending.same_key(&source));
15618                    match result {
15619                        Ok(stream) => {
15620                            self.install_source_stream(source, stream);
15621                        }
15622                        Err(error)
15623                            if self.config.preconfirmations == PreconfirmationMode::Preferred =>
15624                        {
15625                            tracing::warn!(
15626                                stream = source.label(),
15627                                error = %error,
15628                                "Flashblocks reconnect window exhausted; canonical delivery remains active"
15629                            );
15630                            self.reschedule_preferred_flashblock(source);
15631                        }
15632                        Err(error) => return Err(error),
15633                    }
15634                    continue;
15635                }
15636            };
15637
15638            let Some(event) = event else {
15639                return Err(SubscriberError::Provider(
15640                    "Alloy subscriber streams terminated before the subscriber was stopped"
15641                        .to_owned(),
15642                ));
15643            };
15644
15645            match event {
15646                SubscriberEvent::StreamTerminated(source) => {
15647                    // Persist the missing-source intent before the first await.
15648                    // If a control command cancels this poll during reconnect,
15649                    // the next poll will reconcile the desired/live diff.
15650                    if source.is_flashblocks() {
15651                        self.invalidate_flashblock_generation();
15652                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
15653                    }
15654                    self.sources_dirty = true;
15655                    self.bump_stream_revision();
15656                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
15657                        self.sources_dirty = false;
15658                        if let Some(backfill_event) =
15659                            self.normalize_flashblock_event(backfill_event).await?
15660                        {
15661                            self.verify_event_log_blocks(&backfill_event).await?;
15662                            return Ok(Some(backfill_event));
15663                        }
15664                    }
15665                    self.sources_dirty = false;
15666                }
15667                event => {
15668                    let Some(event) = self.normalize_flashblock_event(event).await? else {
15669                        continue;
15670                    };
15671                    self.verify_event_log_blocks(&event).await?;
15672                    return Ok(Some(event));
15673                }
15674            }
15675        }
15676    }
15677
15678    fn invalidate_flashblock_generation(&mut self) {
15679        self.pending_records
15680            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
15681        self.pending_preconfirmation_invalidation = true;
15682        self.reset_flashblock_tracking();
15683        if let Some(provider) = self.provider_ref.as_mut() {
15684            provider.generation = provider.generation.saturating_add(1);
15685        }
15686        if let AlloySubscriberState::Active(streams) = &mut self.state {
15687            streams
15688                .entries
15689                .retain(|entry| !entry.source.is_flashblocks());
15690            streams.normalize_next_index();
15691        }
15692        let reconnect_sources = self
15693            .stream_sources()
15694            .unwrap_or_default()
15695            .into_iter()
15696            .filter(SubscriberStreamSource::is_flashblocks)
15697            .collect::<Vec<_>>();
15698        self.pending_flashblock_reconnects.clear();
15699        self.pending_flashblock_reconnect_sources.clear();
15700        if self.config.preconfirmations == PreconfirmationMode::Required
15701            || self.config.reconnect.enabled
15702        {
15703            for source in reconnect_sources {
15704                self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
15705            }
15706        }
15707        self.sources_dirty = false;
15708        self.bump_stream_revision();
15709    }
15710
15711    async fn normalize_flashblock_event(
15712        &mut self,
15713        event: SubscriberEvent<N>,
15714    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15715        match event {
15716            SubscriberEvent::BasePendingLog { source_id, log } => {
15717                let block_number = log.block_number.ok_or_else(|| {
15718                    SubscriberError::Provider(
15719                        "pendingLogs item is missing its pending block number".into(),
15720                    )
15721                })?;
15722                let transaction_hash = log.transaction_hash.ok_or_else(|| {
15723                    SubscriberError::Provider(
15724                        "pendingLogs item is missing its transaction hash".into(),
15725                    )
15726                })?;
15727                let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
15728                    flashblock.block_number == block_number
15729                        && flashblock.contains_transaction(&transaction_hash)
15730                });
15731                let Some(flashblock) = matching.cloned() else {
15732                    if self
15733                        .latest_preconfirmation
15734                        .as_ref()
15735                        .is_some_and(|latest| block_number < latest.block_number)
15736                    {
15737                        return Ok(None);
15738                    }
15739                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
15740                        return Err(SubscriberError::ResourceExhausted(
15741                            "unmatched pendingLogs exceeded max_pending_records".into(),
15742                        ));
15743                    }
15744                    self.unmatched_pending_logs.push_back((source_id, log));
15745                    return Ok(None);
15746                };
15747                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
15748                Ok(Some(if logs.is_empty() {
15749                    SubscriberEvent::FlashblockObserved
15750                } else {
15751                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
15752                }))
15753            }
15754            SubscriberEvent::BaseFlashblock(payload) => {
15755                let (flashblock, recover_pending_snapshot) =
15756                    self.accept_base_flashblock(payload)?;
15757                let mut logs = Vec::new();
15758                let mut retained = VecDeque::new();
15759                while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() {
15760                    let transaction_hash = log.transaction_hash;
15761                    if log.block_number == Some(flashblock.block_number)
15762                        && transaction_hash
15763                            .as_ref()
15764                            .is_some_and(|hash| flashblock.contains_transaction(hash))
15765                    {
15766                        let _ = source_id;
15767                        logs.push(log);
15768                    } else if log
15769                        .block_number
15770                        .is_some_and(|number| number >= flashblock.block_number)
15771                    {
15772                        retained.push_back((source_id, log));
15773                    } else {
15774                        // A late log for an older speculative block can no
15775                        // longer be applied to the active cumulative branch.
15776                    }
15777                }
15778                self.unmatched_pending_logs = retained;
15779
15780                let indexed_recovery = recover_pending_snapshot.then(|| {
15781                    let payload_id = flashblock
15782                        .payload_id
15783                        .expect("indexed recovery carries a payload id");
15784                    let index = flashblock.index.expect("indexed recovery carries an index");
15785                    let last_diff = self
15786                        .base_flashblock_transactions
15787                        .as_ref()
15788                        .filter(|(known_payload, known_index, _, _)| {
15789                            *known_payload == payload_id && *known_index == index
15790                        })
15791                        .map(|(_, _, _, last_diff)| last_diff.clone())
15792                        .unwrap_or_default();
15793                    (payload_id, index, last_diff)
15794                });
15795                if recover_pending_snapshot {
15796                    if let Some(event) = self
15797                        .fetch_pending_flashblock(indexed_recovery)
15798                        .await
15799                        .map_err(PendingFlashblockPollError::into_subscriber)?
15800                    {
15801                        return Ok(Some(event));
15802                    }
15803                    self.invalidate_flashblock_generation();
15804                    return Ok(Some(SubscriberEvent::FlashblockInvalidated));
15805                }
15806                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
15807                Ok(Some(if logs.is_empty() {
15808                    SubscriberEvent::FlashblockObserved
15809                } else {
15810                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
15811                }))
15812            }
15813            SubscriberEvent::OpFlashblockTick => self.poll_op_pending_flashblock().await,
15814            SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await,
15815            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
15816                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
15817                Ok(Some(if logs.is_empty() {
15818                    SubscriberEvent::FlashblockObserved
15819                } else {
15820                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
15821                }))
15822            }
15823            SubscriberEvent::FlashblockObserved => Ok(None),
15824            event => Ok(Some(event)),
15825        }
15826    }
15827
15828    async fn fetch_certified_canonical_head(
15829        &mut self,
15830    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
15831        if self.chain_id.and_then(flashblocks_adapter)
15832            == Some(FlashblocksAdapter::PendingStatePolling)
15833        {
15834            if !self.reserve_flashblock_rpc_methods(2) {
15835                return Ok(None);
15836            }
15837            self.flashblocks_rpc_metrics.pending_block_requests = self
15838                .flashblocks_rpc_metrics
15839                .pending_block_requests
15840                .saturating_add(1);
15841            let pending = self
15842                .fetch_op_pending_block()
15843                .await
15844                .map_err(PendingFlashblockPollError::into_subscriber)?
15845                .ok_or_else(|| {
15846                    SubscriberError::Provider(
15847                        "provider returned no OP pending block while certifying its parent".into(),
15848                    )
15849                })?;
15850            let header = self
15851                .certify_op_pending_parent(&pending)
15852                .await
15853                .map_err(PendingFlashblockPollError::into_subscriber)?;
15854            let certified = BlockRef {
15855                number: header.number(),
15856                hash: header.hash(),
15857                parent_hash: Some(header.parent_hash()),
15858                timestamp: Some(header.timestamp()),
15859            };
15860            if self.last_certified_canonical_head.as_ref() == Some(&certified) {
15861                return Ok(None);
15862            }
15863            self.last_certified_canonical_head = Some(certified);
15864            return Ok(Some(SubscriberEvent::BlockHeader(header)));
15865        }
15866        self.flashblocks_rpc_metrics.canonical_head_requests = self
15867            .flashblocks_rpc_metrics
15868            .canonical_head_requests
15869            .saturating_add(1);
15870        let block = self
15871            .provider
15872            .get_block_by_number(BlockNumberOrTag::Latest)
15873            .await
15874            .map_err(provider_error)?
15875            .ok_or_else(|| {
15876                SubscriberError::Provider(
15877                    "provider returned no latest block while certifying canonical head".into(),
15878                )
15879            })?;
15880        let header = block.header();
15881        if header.hash().is_zero() {
15882            return Err(SubscriberError::Provider(
15883                "provider returned a placeholder hash for the latest canonical head".into(),
15884            ));
15885        }
15886        let certified = BlockRef {
15887            number: header.number(),
15888            hash: header.hash(),
15889            parent_hash: Some(header.parent_hash()),
15890            timestamp: Some(header.timestamp()),
15891        };
15892        if self.last_certified_canonical_head.as_ref() == Some(&certified) {
15893            return Ok(None);
15894        }
15895        self.last_certified_canonical_head = Some(certified);
15896        Ok(Some(SubscriberEvent::BlockHeader(header.clone())))
15897    }
15898
15899    fn accept_base_flashblock(
15900        &mut self,
15901        payload: BaseFlashblockWirePayload,
15902    ) -> Result<(FlashblockRef, bool), SubscriberError> {
15903        let provider = self.provider_ref.clone().ok_or({
15904            SubscriberError::InvalidConfig(
15905                "Flashblocks require a stable provider ref from a pinned provider lease",
15906            )
15907        })?;
15908
15909        let (flashblock, recover_pending_snapshot) = match payload {
15910            BaseFlashblockWirePayload::Indexed(payload) => {
15911                if payload.index == 0 {
15912                    let base = payload.base.clone().ok_or_else(|| {
15913                        SubscriberError::Provider(
15914                            "indexed newFlashblocks item zero omitted its base header".into(),
15915                        )
15916                    })?;
15917                    self.base_flashblock_header = Some((payload.payload_id, base));
15918                }
15919
15920                let base = self
15921                    .base_flashblock_header
15922                    .as_ref()
15923                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
15924                    .map(|(_, base)| base);
15925                let block_number = base.map(|base| base.block_number).or_else(|| {
15926                    payload
15927                        .metadata
15928                        .as_ref()
15929                        .map(|metadata| metadata.block_number)
15930                });
15931                let block_number = block_number.ok_or_else(|| {
15932                    SubscriberError::Provider(
15933                        "indexed newFlashblocks payload omitted both base and metadata block number"
15934                            .into(),
15935                    )
15936                })?;
15937                let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
15938                let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
15939                    Some((known_payload, known_index, transactions, last_diff))
15940                        if *known_payload == payload.payload_id =>
15941                    {
15942                        if payload.index < *known_index {
15943                            return self
15944                                .latest_preconfirmation
15945                                .clone()
15946                                .map(|flashblock| (flashblock, false))
15947                                .ok_or_else(|| {
15948                                    SubscriberError::Provider(
15949                                        "regressive indexed Flashblock arrived without an active snapshot"
15950                                            .into(),
15951                                    )
15952                                });
15953                        }
15954                        if payload.index == *known_index {
15955                            if *last_diff != diff_transactions {
15956                                return Err(SubscriberError::Provider(
15957                                    "conflicting duplicate indexed Flashblock payload".into(),
15958                                ));
15959                            }
15960                        } else {
15961                            if diff_transactions
15962                                .iter()
15963                                .any(|hash| transactions.contains(hash))
15964                            {
15965                                return Err(SubscriberError::Provider(
15966                                    "indexed Flashblock repeated a transaction from an earlier diff"
15967                                        .into(),
15968                                ));
15969                            }
15970                            transactions.extend(diff_transactions.iter().copied());
15971                            *known_index = payload.index;
15972                            *last_diff = diff_transactions;
15973                        }
15974                        transactions.clone()
15975                    }
15976                    _ => {
15977                        self.base_flashblock_transactions = Some((
15978                            payload.payload_id,
15979                            payload.index,
15980                            diff_transactions.clone(),
15981                            diff_transactions.clone(),
15982                        ));
15983                        diff_transactions
15984                    }
15985                };
15986                let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
15987                let transactions_root = payload
15988                    .diff
15989                    .transactions_root
15990                    .and_then(non_placeholder_hash);
15991                let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
15992                let state_root = non_placeholder_hash(payload.diff.state_root);
15993                let timestamp = base.map(|base| base.timestamp);
15994                let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
15995                let beneficiary = base.and_then(|base| base.beneficiary);
15996                let prevrandao = base
15997                    .and_then(|base| base.prevrandao)
15998                    .and_then(non_placeholder_hash);
15999                let gas_limit = base.and_then(|base| base.gas_limit);
16000                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16001                    provider: &provider,
16002                    payload_id: Some(payload.payload_id),
16003                    index: Some(payload.index),
16004                    block_number,
16005                    partial_block_hash,
16006                    parent_hash,
16007                    state_root,
16008                    transactions_root,
16009                    transaction_hashes: &transaction_hashes,
16010                    timestamp,
16011                    base_fee_per_gas,
16012                    beneficiary,
16013                    prevrandao,
16014                    gas_limit,
16015                });
16016                let flashblock = FlashblockRef {
16017                    provider,
16018                    payload_id: Some(payload.payload_id),
16019                    index: Some(payload.index),
16020                    block_number,
16021                    content_hash,
16022                    partial_block_hash,
16023                    parent_hash,
16024                    state_root,
16025                    transactions_root,
16026                    transaction_hashes,
16027                    timestamp,
16028                    base_fee_per_gas,
16029                    beneficiary,
16030                    prevrandao,
16031                    gas_limit,
16032                };
16033                if let Some(previous) = self.latest_preconfirmation.as_ref()
16034                    && previous.same_payload(&flashblock)
16035                    && previous.index == flashblock.index
16036                    && previous.content_hash != flashblock.content_hash
16037                {
16038                    return Err(SubscriberError::Provider(
16039                        "conflicting duplicate indexed Flashblock content".into(),
16040                    ));
16041                }
16042                let recover = match self.latest_preconfirmation.as_ref() {
16043                    Some(previous) if previous.same_payload(&flashblock) => {
16044                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
16045                        {
16046                            if current < previous {
16047                                return Ok((flashblock, false));
16048                            }
16049                            current > previous.saturating_add(1)
16050                        } else {
16051                            false
16052                        }
16053                    }
16054                    Some(_) => payload.index != 0,
16055                    None => payload.index != 0,
16056                };
16057                (flashblock, recover)
16058            }
16059            BaseFlashblockWirePayload::Block(payload) => {
16060                let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
16061                let parent_hash = non_placeholder_hash(payload.parent_hash);
16062                let state_root = non_placeholder_hash(payload.state_root);
16063                let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
16064                let partial_block_hash = non_placeholder_hash(payload.hash);
16065                let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
16066                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16067                    provider: &provider,
16068                    payload_id: None,
16069                    index: None,
16070                    block_number: payload.number,
16071                    partial_block_hash,
16072                    parent_hash,
16073                    state_root,
16074                    transactions_root,
16075                    transaction_hashes: &transaction_hashes,
16076                    timestamp: Some(payload.timestamp),
16077                    base_fee_per_gas: payload.base_fee_per_gas,
16078                    beneficiary: payload.miner,
16079                    prevrandao,
16080                    gas_limit: payload.gas_limit,
16081                });
16082                let flashblock = FlashblockRef {
16083                    provider,
16084                    payload_id: None,
16085                    index: None,
16086                    block_number: payload.number,
16087                    content_hash,
16088                    partial_block_hash,
16089                    parent_hash,
16090                    state_root,
16091                    transactions_root,
16092                    transaction_hashes,
16093                    timestamp: Some(payload.timestamp),
16094                    base_fee_per_gas: payload.base_fee_per_gas,
16095                    beneficiary: payload.miner,
16096                    prevrandao,
16097                    gas_limit: payload.gas_limit,
16098                };
16099                if let Some(previous) = self.latest_preconfirmation.as_ref()
16100                    && flashblock.same_payload(previous)
16101                    && flashblock.content_hash != previous.content_hash
16102                    && !flashblock.is_cumulative_successor_of(previous)
16103                {
16104                    return Err(SubscriberError::Provider(
16105                        "cumulative Flashblock transaction membership is non-monotonic".into(),
16106                    ));
16107                }
16108                (flashblock, false)
16109            }
16110        };
16111        Ok((flashblock, recover_pending_snapshot))
16112    }
16113
16114    async fn poll_op_pending_flashblock(
16115        &mut self,
16116    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16117        match self.fetch_pending_flashblock(None).await {
16118            Ok(event) => {
16119                self.consecutive_flashblock_poll_failures = 0;
16120                Ok(event)
16121            }
16122            Err(PendingFlashblockPollError::Request(error)) => {
16123                self.flashblocks_rpc_metrics.failed_requests = self
16124                    .flashblocks_rpc_metrics
16125                    .failed_requests
16126                    .saturating_add(1);
16127                self.consecutive_flashblock_poll_failures =
16128                    self.consecutive_flashblock_poll_failures.saturating_add(1);
16129                if self.consecutive_flashblock_poll_failures
16130                    >= self.config.max_consecutive_flashblock_poll_failures
16131                {
16132                    return Err(error);
16133                }
16134                tracing::warn!(
16135                    consecutive_failures = self.consecutive_flashblock_poll_failures,
16136                    failure_limit = self.config.max_consecutive_flashblock_poll_failures,
16137                    error = %error,
16138                    "Optimism pending-state Flashblocks request failed; retrying on the next tick"
16139                );
16140                Ok(None)
16141            }
16142            Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
16143        }
16144    }
16145
16146    async fn fetch_pending_flashblock(
16147        &mut self,
16148        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
16149    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
16150        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
16151            == Some(FlashblocksAdapter::PendingStatePolling);
16152        if samples_pending_range {
16153            let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
16154            if !self.reserve_flashblock_rpc_methods(fixed_methods) {
16155                return Ok(None);
16156            }
16157        }
16158        let state_provider = if samples_pending_range {
16159            self.flashblocks_state_provider
16160                .as_ref()
16161                .unwrap_or(&self.provider)
16162        } else {
16163            &self.provider
16164        };
16165        let latest = if samples_pending_range {
16166            None
16167        } else {
16168            self.flashblocks_rpc_metrics.canonical_head_requests = self
16169                .flashblocks_rpc_metrics
16170                .canonical_head_requests
16171                .saturating_add(1);
16172            Some(
16173                state_provider
16174                    .get_block_number()
16175                    .await
16176                    .map_err(pending_flashblock_request_error)?,
16177            )
16178        };
16179        self.flashblocks_rpc_metrics.pending_block_requests = self
16180            .flashblocks_rpc_metrics
16181            .pending_block_requests
16182            .saturating_add(1);
16183        let pending_block = if samples_pending_range {
16184            self.fetch_op_pending_block().await?
16185        } else {
16186            self.provider
16187                .get_block_by_number(BlockNumberOrTag::Pending)
16188                .await
16189                .map_err(pending_flashblock_request_error)?
16190        };
16191        let Some(block) = pending_block else {
16192            if self.config.preconfirmations == PreconfirmationMode::Required {
16193                return Err(PendingFlashblockPollError::Request(
16194                    SubscriberError::Provider(
16195                        "Flashblocks provider returned no pending block".into(),
16196                    ),
16197                ));
16198            }
16199            return Ok(None);
16200        };
16201        let latest = if samples_pending_range {
16202            self.certify_op_pending_parent(&block).await?.number()
16203        } else {
16204            latest.expect("non-OP pending recovery fetched a canonical height")
16205        };
16206        let header = block.header();
16207        if header.number() <= latest {
16208            return Ok(None);
16209        }
16210
16211        let provider = self.provider_ref.clone().ok_or({
16212            PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
16213                "Flashblocks require a stable provider ref from a pinned provider lease",
16214            ))
16215        })?;
16216        let parent_hash = Some(header.parent_hash());
16217        let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
16218            hashes.to_vec()
16219        } else if let Some(transactions) = block.transactions().as_transactions() {
16220            transactions
16221                .iter()
16222                .map(|transaction| transaction.tx_hash())
16223                .collect()
16224        } else {
16225            Vec::new()
16226        };
16227        let state_root = non_placeholder_hash(header.state_root());
16228        let transactions_root = non_placeholder_hash(header.transactions_root());
16229        let partial_block_hash = non_placeholder_hash(header.hash());
16230        let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
16231        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16232            provider: &provider,
16233            payload_id: None,
16234            index: None,
16235            block_number: header.number(),
16236            partial_block_hash,
16237            parent_hash,
16238            state_root,
16239            transactions_root,
16240            transaction_hashes: &transaction_hashes,
16241            timestamp: Some(header.timestamp()),
16242            base_fee_per_gas: header.base_fee_per_gas(),
16243            beneficiary: Some(header.beneficiary()),
16244            prevrandao,
16245            gas_limit: Some(header.gas_limit()),
16246        });
16247        let flashblock = FlashblockRef {
16248            provider,
16249            payload_id: None,
16250            index: None,
16251            block_number: header.number(),
16252            content_hash,
16253            partial_block_hash,
16254            parent_hash,
16255            state_root,
16256            transactions_root,
16257            transaction_hashes,
16258            timestamp: Some(header.timestamp()),
16259            base_fee_per_gas: header.base_fee_per_gas(),
16260            beneficiary: Some(header.beneficiary()),
16261            prevrandao,
16262            gas_limit: Some(header.gas_limit()),
16263        };
16264        if samples_pending_range
16265            && self
16266                .latest_preconfirmation
16267                .as_ref()
16268                .is_some_and(|previous| !previous.same_payload(&flashblock))
16269        {
16270            // Revoke as soon as the sampled payload changes, before any
16271            // follow-up receipt await can fail or be cancelled.
16272            self.invalidate_preconfirmation_snapshot();
16273        }
16274        if let Some((payload_id, index, last_diff)) = indexed_recovery {
16275            self.base_flashblock_transactions = Some((
16276                payload_id,
16277                index,
16278                flashblock.transaction_hashes.clone(),
16279                last_diff,
16280            ));
16281        }
16282        let repeats_pending_snapshot = self
16283            .latest_preconfirmation
16284            .as_ref()
16285            .is_some_and(|previous| previous == &flashblock);
16286        if repeats_pending_snapshot && !samples_pending_range {
16287            return Ok(None);
16288        }
16289
16290        if let Some(previous) = self.latest_preconfirmation.as_ref()
16291            && flashblock.same_payload(previous)
16292            && !flashblock.is_cumulative_successor_of(previous)
16293        {
16294            if samples_pending_range {
16295                // OP pending-state reads are not atomic and paid endpoints can
16296                // briefly expose a shorter backend view. Never publish the
16297                // regression. Revoke the active overlay and require a fresh,
16298                // internally coherent sample on a later tick instead.
16299                self.invalidate_preconfirmation_snapshot();
16300                return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16301            }
16302            return Err(PendingFlashblockPollError::Integrity(
16303                SubscriberError::Provider(
16304                    "sampled cumulative Flashblock transaction membership is non-monotonic".into(),
16305                ),
16306            ));
16307        }
16308
16309        let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
16310        if samples_pending_range {
16311            let (mut receipt_logs, completed_receipts, unavailable_receipts) =
16312                self.fetch_pending_transaction_receipts(&flashblock).await?;
16313            logs.append(&mut receipt_logs);
16314            logs.retain(|log| log.block_number == Some(flashblock.block_number));
16315            for log in &logs {
16316                let transaction_hash = log.transaction_hash.ok_or_else(|| {
16317                    PendingFlashblockPollError::Integrity(SubscriberError::Provider(
16318                        "pre-confirmed log is missing its transaction hash".into(),
16319                    ))
16320                })?;
16321                if !flashblock.contains_transaction(&transaction_hash) {
16322                    self.flashblocks_rpc_metrics.raced_samples =
16323                        self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
16324                    return Ok(None);
16325                }
16326            }
16327            let logs = self
16328                .filter_preconfirmed_logs(&flashblock, logs)
16329                .map_err(PendingFlashblockPollError::Integrity)?;
16330            self.preconfirmed_unavailable_receipts
16331                .extend(unavailable_receipts);
16332            for transaction_hash in &completed_receipts {
16333                self.preconfirmed_unavailable_receipts
16334                    .remove(transaction_hash);
16335            }
16336            self.preconfirmed_receipted_transactions
16337                .extend(completed_receipts);
16338            if repeats_pending_snapshot && logs.is_empty() {
16339                return Ok(None);
16340            }
16341            return Ok(Some(if logs.is_empty() {
16342                SubscriberEvent::FlashblockObserved
16343            } else {
16344                SubscriberEvent::PreconfirmedLogs { flashblock, logs }
16345            }));
16346        }
16347        let logs = self
16348            .filter_preconfirmed_logs(&flashblock, logs)
16349            .map_err(PendingFlashblockPollError::Integrity)?;
16350        Ok(Some(if logs.is_empty() {
16351            SubscriberEvent::FlashblockObserved
16352        } else {
16353            SubscriberEvent::PreconfirmedLogs { flashblock, logs }
16354        }))
16355    }
16356
16357    async fn fetch_pending_logs(
16358        &mut self,
16359        pending_block_number: u64,
16360    ) -> Result<Vec<Log>, PendingFlashblockPollError> {
16361        let mut logs = Vec::new();
16362        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
16363            == Some(FlashblocksAdapter::PendingStatePolling);
16364        let state_provider = if samples_pending_range {
16365            self.flashblocks_state_provider
16366                .as_ref()
16367                .unwrap_or(&self.provider)
16368        } else {
16369            &self.provider
16370        };
16371        for filter in self.log_stream_filters() {
16372            self.flashblocks_rpc_metrics.pending_log_requests = self
16373                .flashblocks_rpc_metrics
16374                .pending_log_requests
16375                .saturating_add(1);
16376            let filter = if samples_pending_range {
16377                filter
16378                    .from_block(pending_block_number)
16379                    .to_block(BlockNumberOrTag::Pending)
16380            } else {
16381                filter
16382                    .from_block(BlockNumberOrTag::Pending)
16383                    .to_block(BlockNumberOrTag::Pending)
16384            };
16385            logs.extend(
16386                state_provider
16387                    .get_logs(&filter)
16388                    .await
16389                    .map_err(pending_flashblock_request_error)?,
16390            );
16391        }
16392        if samples_pending_range {
16393            logs.retain(|log| log.block_number == Some(pending_block_number));
16394        }
16395        Ok(logs)
16396    }
16397
16398    async fn fetch_pending_transaction_receipts(
16399        &mut self,
16400        flashblock: &FlashblockRef,
16401    ) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
16402        let receipt_allowance = self.pending_receipt_request_allowance();
16403        let receipt_limit = self
16404            .config
16405            .max_pending_transaction_receipts_per_tick
16406            .min(receipt_allowance);
16407        if receipt_limit == 0 {
16408            return Ok((Vec::new(), Vec::new(), Vec::new()));
16409        }
16410        let mut transaction_hashes = Vec::with_capacity(receipt_limit);
16411        for transaction_hash in &flashblock.transaction_hashes {
16412            if !self
16413                .preconfirmed_receipted_transactions
16414                .contains(transaction_hash)
16415                && !self
16416                    .preconfirmed_unavailable_receipts
16417                    .contains(transaction_hash)
16418            {
16419                transaction_hashes.push(*transaction_hash);
16420                if transaction_hashes.len() == receipt_limit {
16421                    break;
16422                }
16423            }
16424        }
16425        if transaction_hashes.len() < receipt_limit {
16426            for transaction_hash in &flashblock.transaction_hashes {
16427                if self
16428                    .preconfirmed_unavailable_receipts
16429                    .contains(transaction_hash)
16430                {
16431                    transaction_hashes.push(*transaction_hash);
16432                    if transaction_hashes.len() == receipt_limit {
16433                        break;
16434                    }
16435                }
16436            }
16437        }
16438        if transaction_hashes.is_empty() {
16439            return Ok((Vec::new(), Vec::new(), Vec::new()));
16440        }
16441        let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
16442        debug_assert!(reserved, "receipt allowance must remain reserved until use");
16443        if !reserved {
16444            return Ok((Vec::new(), Vec::new(), Vec::new()));
16445        }
16446        self.flashblocks_rpc_metrics.pending_receipt_requests = self
16447            .flashblocks_rpc_metrics
16448            .pending_receipt_requests
16449            .saturating_add(transaction_hashes.len() as u64);
16450        let state_provider = self
16451            .flashblocks_state_provider
16452            .as_ref()
16453            .unwrap_or(&self.provider);
16454        let client = state_provider.client();
16455        let mut batch = BatchRequest::new(client);
16456        let mut waiters = Vec::with_capacity(transaction_hashes.len());
16457        for transaction_hash in transaction_hashes {
16458            let waiter = batch
16459                .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
16460                .map_err(pending_flashblock_request_error)?;
16461            waiters.push((transaction_hash, waiter));
16462        }
16463        batch
16464            .send()
16465            .await
16466            .map_err(pending_flashblock_request_error)?;
16467        let mut logs = Vec::new();
16468        let mut completed = Vec::new();
16469        let mut unavailable = Vec::new();
16470        for (transaction_hash, waiter) in waiters {
16471            let value = waiter.await.map_err(pending_flashblock_request_error)?;
16472            if let Some(mut receipt_logs) =
16473                normalize_pending_transaction_receipt(transaction_hash, value)
16474                    .map_err(PendingFlashblockPollError::Integrity)?
16475            {
16476                self.flashblocks_rpc_metrics.pending_receipts_completed = self
16477                    .flashblocks_rpc_metrics
16478                    .pending_receipts_completed
16479                    .saturating_add(1);
16480                logs.append(&mut receipt_logs);
16481                completed.push(transaction_hash);
16482            } else {
16483                self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
16484                    .flashblocks_rpc_metrics
16485                    .pending_receipts_unavailable
16486                    .saturating_add(1);
16487                unavailable.push(transaction_hash);
16488            }
16489        }
16490        Ok((logs, completed, unavailable))
16491    }
16492
16493    fn pending_receipt_request_allowance(&mut self) -> usize {
16494        self.prune_flashblock_rpc_request_times();
16495        let rolling_capacity = self
16496            .config
16497            .max_flashblock_rpc_requests_per_second
16498            .saturating_sub(self.flashblock_rpc_request_times.len());
16499        rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
16500    }
16501
16502    fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
16503        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
16504        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
16505        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
16506        self.pending_receipt_requests_per_second_capacity()
16507            .checked_div(ticks_per_second)
16508            .unwrap_or(0)
16509    }
16510
16511    fn pending_receipt_requests_per_second_capacity(&self) -> usize {
16512        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
16513        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
16514        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
16515        let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
16516        let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
16517        if needs_header_block_stream(&self.interests) {
16518            let canonical_interval_nanos =
16519                self.config.canonical_head_poll_interval.as_nanos().max(1);
16520            let canonical_ticks = Duration::from_secs(1)
16521                .as_nanos()
16522                .div_ceil(canonical_interval_nanos);
16523            let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
16524            reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
16525        }
16526        self.config
16527            .max_flashblock_rpc_requests_per_second
16528            .saturating_sub(reserved_methods)
16529    }
16530
16531    fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
16532        self.prune_flashblock_rpc_request_times();
16533        if self
16534            .flashblock_rpc_request_times
16535            .len()
16536            .saturating_add(methods)
16537            > self.config.max_flashblock_rpc_requests_per_second
16538        {
16539            return false;
16540        }
16541        let now = Instant::now();
16542        for _ in 0..methods {
16543            self.flashblock_rpc_request_times.push_back(now);
16544        }
16545        true
16546    }
16547
16548    fn prune_flashblock_rpc_request_times(&mut self) {
16549        let now = Instant::now();
16550        while self
16551            .flashblock_rpc_request_times
16552            .front()
16553            .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
16554        {
16555            self.flashblock_rpc_request_times.pop_front();
16556        }
16557    }
16558
16559    fn filter_preconfirmed_logs(
16560        &mut self,
16561        flashblock: &FlashblockRef,
16562        mut logs: Vec<Log>,
16563    ) -> Result<Vec<Log>, SubscriberError> {
16564        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
16565            == Some(FlashblocksAdapter::PendingStatePolling);
16566        if self
16567            .latest_preconfirmation
16568            .as_ref()
16569            .is_some_and(|previous| !previous.same_payload(flashblock))
16570        {
16571            // A new payload revokes the previous overlay even when none of the
16572            // caller's log filters matched in the replacement. Otherwise a
16573            // quiet block could leave stale speculative signing authority
16574            // active until an unrelated canonical pool event arrived.
16575            self.invalidate_preconfirmation_snapshot();
16576        }
16577        if self
16578            .latest_preconfirmation
16579            .as_ref()
16580            .is_none_or(|previous| !previous.same_payload(flashblock))
16581        {
16582            self.preconfirmed_seen_logs.clear();
16583        }
16584        if let Some(previous) = self.latest_preconfirmation.as_ref()
16585            && previous.same_payload(flashblock)
16586            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
16587            && current_index < previous_index
16588        {
16589            return Ok(Vec::new());
16590        }
16591        self.latest_preconfirmation = Some(flashblock.clone());
16592
16593        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
16594        let mut filtered = Vec::new();
16595        for mut log in logs {
16596            if log.removed || log.block_number != Some(flashblock.block_number) {
16597                return Err(SubscriberError::Provider(
16598                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
16599                ));
16600            }
16601            let transaction_hash = log.transaction_hash.ok_or_else(|| {
16602                SubscriberError::Provider(
16603                    "pre-confirmed log is missing its transaction hash".into(),
16604                )
16605            })?;
16606            let log_index = log.log_index.ok_or_else(|| {
16607                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
16608            })?;
16609            let transaction_index =
16610                flashblock
16611                    .transaction_index(&transaction_hash)
16612                    .ok_or_else(|| {
16613                        SubscriberError::Provider(
16614                        "pre-confirmed log transaction is absent from the cumulative Flashblock"
16615                            .into(),
16616                    )
16617                    })?;
16618            if log
16619                .transaction_index
16620                .is_some_and(|reported| reported != transaction_index)
16621            {
16622                return Err(SubscriberError::Provider(
16623                    "pre-confirmed log transaction index disagrees with cumulative membership"
16624                        .into(),
16625                ));
16626            }
16627            let reported_hash = log.block_hash.and_then(non_placeholder_hash);
16628            if !samples_pending_range
16629                && let (Some(reported), Some(expected)) =
16630                    (reported_hash, flashblock.partial_block_hash)
16631                && reported != expected
16632            {
16633                return Err(SubscriberError::Provider(
16634                    "pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
16635                        .into(),
16636                ));
16637            }
16638            log.block_hash = Some(flashblock.content_hash);
16639            log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
16640            log.transaction_index = Some(transaction_index);
16641            if self
16642                .preconfirmed_seen_logs
16643                .insert((transaction_hash, log_index))
16644                && log_matches_any_interest(&log, &self.interests)
16645            {
16646                filtered.push(log);
16647            }
16648        }
16649        Ok(filtered)
16650    }
16651
16652    async fn verify_event_log_blocks(
16653        &mut self,
16654        event: &SubscriberEvent<N>,
16655    ) -> Result<(), SubscriberError> {
16656        if !self.config.verify_log_block_context {
16657            return Ok(());
16658        }
16659        match event {
16660            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
16661            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
16662                for log in logs {
16663                    self.verify_log_block_context(log).await?;
16664                }
16665                Ok(())
16666            }
16667            SubscriberEvent::BlockHeader(_)
16668            | SubscriberEvent::PendingHash(_)
16669            | SubscriberEvent::PendingHashes(_)
16670            | SubscriberEvent::BasePendingLog { .. }
16671            | SubscriberEvent::BaseFlashblock(_)
16672            | SubscriberEvent::OpFlashblockTick
16673            | SubscriberEvent::CanonicalHeadTick
16674            | SubscriberEvent::PreconfirmedLogs { .. }
16675            | SubscriberEvent::FlashblockInvalidated
16676            | SubscriberEvent::FlashblockObserved
16677            | SubscriberEvent::StreamTerminated(_) => Ok(()),
16678        }
16679    }
16680
16681    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
16682        if log.removed {
16683            return Ok(());
16684        }
16685        let number = log.block_number.ok_or_else(|| {
16686            SubscriberError::Provider(
16687                "canonical log is missing its block number during context verification".into(),
16688            )
16689        })?;
16690        let hash = log.block_hash.ok_or_else(|| {
16691            SubscriberError::Provider(
16692                "canonical log is missing its block hash during context verification".into(),
16693            )
16694        })?;
16695        let key = (number, hash);
16696        if self.verified_log_blocks.contains_key(&key) {
16697            return Ok(());
16698        }
16699        let provider = self
16700            .log_verification_provider
16701            .as_ref()
16702            .unwrap_or(&self.provider);
16703        let block = provider
16704            .get_block_by_number(BlockNumberOrTag::Number(number))
16705            .await
16706            .map_err(provider_error)?
16707            .ok_or_else(|| {
16708                SubscriberError::Provider(format!(
16709                    "canonical log block {number} is unavailable during context verification"
16710                ))
16711            })?;
16712        let header = block.header();
16713        let verified = BlockRef {
16714            number: header.number(),
16715            hash: header.hash(),
16716            parent_hash: Some(header.parent_hash()),
16717            timestamp: Some(header.timestamp()),
16718        };
16719        if verified.number != number
16720            || verified.hash != hash
16721            || log
16722                .block_timestamp
16723                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
16724        {
16725            return Err(SubscriberError::Provider(format!(
16726                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
16727            )));
16728        }
16729        self.verified_log_blocks.insert(key, verified);
16730        self.verified_log_block_order.push_back(key);
16731        let capacity = self.config.reconnect.dedupe_window.max(1);
16732        while self.verified_log_block_order.len() > capacity {
16733            if let Some(evicted) = self.verified_log_block_order.pop_front() {
16734                self.verified_log_blocks.remove(&evicted);
16735            }
16736        }
16737        Ok(())
16738    }
16739
16740    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
16741        self.enqueue_event_with_excluded_owners(event, None);
16742    }
16743
16744    fn buffer_reconcile_event_for_owners(
16745        &mut self,
16746        event: &SubscriberEvent<N>,
16747        target_epochs: &HashSet<SubscriberOwnerEpoch>,
16748    ) {
16749        match event {
16750            SubscriberEvent::Log { log, .. } => {
16751                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
16752            }
16753            SubscriberEvent::BackfilledLogs { logs, .. } => {
16754                for log in logs {
16755                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
16756                }
16757            }
16758            SubscriberEvent::Logs(logs) => {
16759                for log in logs {
16760                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
16761                }
16762            }
16763            SubscriberEvent::BlockHeader(_)
16764            | SubscriberEvent::PendingHash(_)
16765            | SubscriberEvent::PendingHashes(_)
16766            | SubscriberEvent::BasePendingLog { .. }
16767            | SubscriberEvent::BaseFlashblock(_)
16768            | SubscriberEvent::OpFlashblockTick
16769            | SubscriberEvent::CanonicalHeadTick
16770            | SubscriberEvent::PreconfirmedLogs { .. }
16771            | SubscriberEvent::FlashblockInvalidated
16772            | SubscriberEvent::FlashblockObserved
16773            | SubscriberEvent::StreamTerminated(_) => {}
16774        }
16775    }
16776
16777    fn buffer_reconcile_log_for_owners(
16778        &mut self,
16779        log: &Log,
16780        source: InputSource,
16781        target_epochs: &HashSet<SubscriberOwnerEpoch>,
16782    ) {
16783        let record = self.with_chain_id(log_input_record(log.clone(), source));
16784        let owners = self
16785            .staged_owners_for_record(&record)
16786            .into_iter()
16787            .filter(|owner| target_epochs.contains(owner))
16788            .collect::<Vec<_>>();
16789        if !owners.is_empty() {
16790            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
16791        }
16792    }
16793
16794    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
16795        let mut retained = VecDeque::new();
16796        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
16797            let mut promoted = Vec::new();
16798            buffered.owners.retain(|owner| {
16799                if target_epochs.contains(owner) {
16800                    promoted.push(owner.clone());
16801                    false
16802                } else {
16803                    true
16804                }
16805            });
16806            if promoted.is_empty() {
16807                retained.push_back(buffered);
16808                continue;
16809            }
16810            let promoted_record = if buffered.owners.is_empty() {
16811                buffered.record
16812            } else {
16813                let record = buffered.record.clone();
16814                retained.push_back(buffered);
16815                record
16816            };
16817            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
16818        }
16819        self.pending_reconcile_owner_records = retained;
16820    }
16821
16822    fn seed_reconciled_filter_anchors(
16823        &mut self,
16824        plans: &[SubscriberOwnerReconcilePlan<N>],
16825        through: u64,
16826    ) {
16827        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
16828            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
16829                continue;
16830            };
16831            let anchor = self
16832                .last_seen_log_blocks
16833                .entry(source_id)
16834                .or_insert(through);
16835            *anchor = (*anchor).max(through);
16836        }
16837    }
16838
16839    fn enqueue_event_excluding_owners(
16840        &mut self,
16841        event: SubscriberEvent<N>,
16842        excluded: &HashSet<SubscriberOwnerEpoch>,
16843    ) {
16844        self.enqueue_event_with_excluded_owners(event, Some(excluded));
16845    }
16846
16847    fn enqueue_event_with_excluded_owners(
16848        &mut self,
16849        event: SubscriberEvent<N>,
16850        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
16851    ) {
16852        match event {
16853            SubscriberEvent::Log { source_id, log } => {
16854                if log_matches_any_interest(&log, &self.interests) {
16855                    let record = log_input_record(log, InputSource::Subscription);
16856                    self.note_log_block(source_id, &record);
16857                    self.enqueue_record_with_excluded_owners(record, excluded);
16858                }
16859            }
16860            SubscriberEvent::BackfilledLogs { source_id, logs } => {
16861                self.enqueue_backfilled_logs_with_excluded_owners(
16862                    logs,
16863                    Some(source_id),
16864                    None,
16865                    None,
16866                    excluded,
16867                );
16868            }
16869            SubscriberEvent::Logs(logs) => {
16870                for log in logs {
16871                    if log_matches_any_interest(&log, &self.interests) {
16872                        self.enqueue_record_with_excluded_owners(
16873                            log_input_record(log, InputSource::Poll),
16874                            excluded,
16875                        );
16876                    }
16877                }
16878            }
16879            SubscriberEvent::BlockHeader(header) => {
16880                if needs_header_block_stream(&self.interests) {
16881                    let record = block_header_input_record::<N>(header);
16882                    self.enqueue_record_with_excluded_owners(record, excluded);
16883                }
16884            }
16885            SubscriberEvent::PendingHash(hash) => {
16886                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
16887                self.enqueue_record_with_excluded_owners(record, excluded);
16888            }
16889            SubscriberEvent::PendingHashes(hashes) => {
16890                for hash in hashes {
16891                    self.enqueue_record_with_excluded_owners(
16892                        pending_hash_input_record::<N>(hash, InputSource::Poll),
16893                        excluded,
16894                    );
16895                }
16896            }
16897            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
16898                for log in logs {
16899                    let record = self
16900                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
16901                    self.push_pending_record(SubscriberInputRecord {
16902                        record,
16903                        scope: SubscriberInputScope::Preconfirmed,
16904                    });
16905                }
16906            }
16907            SubscriberEvent::FlashblockInvalidated => {
16908                self.pending_preconfirmation_invalidation = true;
16909            }
16910            SubscriberEvent::BasePendingLog { .. }
16911            | SubscriberEvent::BaseFlashblock(_)
16912            | SubscriberEvent::OpFlashblockTick
16913            | SubscriberEvent::CanonicalHeadTick
16914            | SubscriberEvent::FlashblockObserved => {}
16915            SubscriberEvent::StreamTerminated(_) => {}
16916        }
16917    }
16918
16919    fn enqueue_backfilled_logs(
16920        &mut self,
16921        logs: Vec<Log>,
16922        source_id: Option<usize>,
16923        owner: Option<&SubscriberOwnerEpoch>,
16924        range: Option<SubscriberBackfill>,
16925    ) {
16926        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
16927    }
16928
16929    fn enqueue_backfilled_logs_with_excluded_owners(
16930        &mut self,
16931        logs: Vec<Log>,
16932        source_id: Option<usize>,
16933        owner: Option<&SubscriberOwnerEpoch>,
16934        range: Option<SubscriberBackfill>,
16935        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
16936    ) {
16937        for log in logs {
16938            if range.as_ref().is_some_and(|range| {
16939                log.block_number.is_some_and(|block| {
16940                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
16941                })
16942            }) {
16943                continue;
16944            }
16945            let matches = match owner {
16946                Some(epoch) => self
16947                    .owned_interests
16948                    .iter()
16949                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
16950                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
16951                None => log_matches_any_interest(&log, &self.interests),
16952            };
16953            if matches {
16954                let record = log_input_record(log, InputSource::Backfill);
16955                if let Some(epoch) = owner {
16956                    self.enqueue_owner_record(record, epoch.clone());
16957                } else {
16958                    if let Some(source_id) = source_id {
16959                        self.note_log_block(source_id, &record);
16960                    }
16961                    self.enqueue_record_with_excluded_owners(record, excluded);
16962                }
16963            }
16964        }
16965    }
16966
16967    fn enqueue_compat_owner_backfilled_logs(
16968        &mut self,
16969        logs: Vec<Log>,
16970        owner: &HandlerId,
16971        range: SubscriberBackfill,
16972    ) {
16973        let interests = self
16974            .owned_interests
16975            .iter()
16976            .find(|entry| {
16977                &entry.owner == owner
16978                    && entry.epoch.is_none()
16979                    && entry.state == SubscriberOwnerState::Active
16980            })
16981            .map(|entry| entry.interests.clone());
16982        let Some(interests) = interests else {
16983            return;
16984        };
16985        for log in logs {
16986            if log.block_number.is_some_and(|block| {
16987                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
16988            }) || !log_matches_any_interest(&log, &interests)
16989            {
16990                continue;
16991            }
16992            let record = log_input_record(log, InputSource::Backfill);
16993            self.enqueue_compat_owner_record(record, owner.clone());
16994        }
16995    }
16996
16997    async fn reconnect_source_stream(
16998        &mut self,
16999        source: SubscriberStreamSource,
17000    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17001        if !source.is_pubsub() {
17002            return Err(stream_terminated_error(&source));
17003        }
17004
17005        if !self.config.reconnect.enabled {
17006            return Err(SubscriberError::Provider(format!(
17007                "Alloy subscriber {} stream terminated and reconnect is disabled",
17008                source.label()
17009            )));
17010        }
17011
17012        let mut attempts = 0usize;
17013        let mut delay = self.config.reconnect.initial_delay;
17014        let mut retry_delay = self.config.reconnect.retry_delay;
17015
17016        loop {
17017            attempts = attempts.saturating_add(1);
17018            if !delay.is_zero() {
17019                tokio::time::sleep(delay).await;
17020            }
17021
17022            match self.reconnect_source_once(source.clone()).await {
17023                Ok(backfill_event) => return Ok(backfill_event),
17024                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
17025                    return Err(SubscriberError::Provider(format!(
17026                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
17027                        source.label()
17028                    )));
17029                }
17030                Err(error) => {
17031                    tracing::warn!(
17032                        stream = source.label(),
17033                        attempts,
17034                        error = %error,
17035                        "Alloy subscriber reconnect attempt failed"
17036                    );
17037                    delay = retry_delay;
17038                    retry_delay =
17039                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
17040                }
17041            }
17042        }
17043    }
17044
17045    async fn reconnect_source_once(
17046        &mut self,
17047        source: SubscriberStreamSource,
17048    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17049        if matches!(
17050            &self.state,
17051            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
17052        ) {
17053            // A prior attempt installed the stream before its catch-up await
17054            // failed or was cancelled. Retry only the unfinished historical
17055            // window; reconnecting again would create a duplicate live source.
17056            let backfill_event = self.backfill_reconnected_source(&source).await?;
17057            self.pending_source_backfills
17058                .retain(|pending| !pending.same_key(&source));
17059            return Ok(backfill_event);
17060        }
17061        let stream = self.connect_source_stream(source.clone()).await?;
17062        if !matches!(self.state, AlloySubscriberState::Active(_)) {
17063            return Err(SubscriberError::Provider(
17064                "Alloy subscriber state changed before reconnect completed".to_owned(),
17065            ));
17066        }
17067        self.install_source_stream(source.clone(), stream);
17068        if self.source_requires_backfill(&source) {
17069            self.queue_source_backfill(source.clone());
17070        }
17071        let backfill_event = self.backfill_reconnected_source(&source).await?;
17072        self.pending_source_backfills
17073            .retain(|pending| !pending.same_key(&source));
17074
17075        Ok(backfill_event)
17076    }
17077
17078    async fn backfill_reconnected_source(
17079        &mut self,
17080        source: &SubscriberStreamSource,
17081    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17082        if source.is_flashblocks() {
17083            return Ok(None);
17084        }
17085        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
17086            return Ok(None);
17087        };
17088        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
17089            return Ok(None);
17090        };
17091
17092        let latest = self
17093            .provider
17094            .get_block_number()
17095            .await
17096            .map_err(provider_error)?;
17097        if latest < from_block {
17098            return Ok(None);
17099        }
17100
17101        let logs = self
17102            .provider
17103            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
17104            .await
17105            .map_err(provider_error)?;
17106        Ok(Some(SubscriberEvent::BackfilledLogs {
17107            source_id: *id,
17108            logs,
17109        }))
17110    }
17111
17112    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
17113        if let Some(block) = record.context.block.as_ref() {
17114            self.last_seen_log_blocks.insert(source_id, block.number);
17115        }
17116    }
17117
17118    fn enqueue_record_with_excluded_owners(
17119        &mut self,
17120        record: ReactiveInputRecord<N>,
17121        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17122    ) {
17123        let record = self.with_chain_id(record);
17124        let mut owners = self.staged_owners_for_record(&record);
17125        if let Some(excluded) = excluded {
17126            owners.retain(|owner| !excluded.contains(owner));
17127        }
17128        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
17129        let owners = self.filter_recent_owner_duplicates(&record, owners);
17130        let compatibility_owners = self.compatibility_owners_for_record(&record);
17131        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
17132            .into_iter()
17133            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
17134        if canonical_duplicate {
17135            if !owners.is_empty() {
17136                self.push_pending_record(SubscriberInputRecord {
17137                    record: record.clone(),
17138                    scope: SubscriberInputScope::OwnerOnly { owners },
17139                });
17140            }
17141            if !newly_served.is_empty() {
17142                for owner in &newly_served {
17143                    self.remember_compatibility_owner_record(&record, owner);
17144                }
17145                self.push_pending_record(SubscriberInputRecord {
17146                    record,
17147                    scope: SubscriberInputScope::OwnerOnlyHandlers {
17148                        owners: newly_served,
17149                    },
17150                });
17151            }
17152            return;
17153        }
17154        self.remember_record(&record);
17155        for owner in already_served.iter().chain(&newly_served) {
17156            self.remember_compatibility_owner_record(&record, owner);
17157        }
17158        self.push_pending_record(SubscriberInputRecord {
17159            record,
17160            scope: if already_served.is_empty() {
17161                SubscriberInputScope::Canonical { owners }
17162            } else {
17163                SubscriberInputScope::CanonicalResidual {
17164                    owners,
17165                    excluded: already_served,
17166                }
17167            },
17168        });
17169    }
17170
17171    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
17172        let record = self.with_chain_id(record);
17173        if self.compatibility_owner_has_seen(&record, &owner) {
17174            return;
17175        }
17176        self.remember_compatibility_owner_record(&record, &owner);
17177        self.push_pending_record(SubscriberInputRecord {
17178            record,
17179            scope: SubscriberInputScope::OwnerOnlyHandlers {
17180                owners: vec![owner],
17181            },
17182        });
17183    }
17184
17185    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
17186        self.owned_interests
17187            .iter()
17188            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
17189            .filter(|entry| {
17190                entry
17191                    .interests
17192                    .iter()
17193                    .any(|interest| interest_matches(interest, &record.input))
17194            })
17195            .map(|entry| entry.owner.clone())
17196            .collect()
17197    }
17198
17199    fn compatibility_owner_has_seen(
17200        &self,
17201        record: &ReactiveInputRecord<N>,
17202        owner: &HandlerId,
17203    ) -> bool {
17204        should_dedupe_record(record)
17205            && self
17206                .recent_compat_owner_input_ref_sets
17207                .get(owner)
17208                .is_some_and(|seen| seen.contains(&record.input_ref()))
17209    }
17210
17211    fn remember_compatibility_owner_record(
17212        &mut self,
17213        record: &ReactiveInputRecord<N>,
17214        owner: &HandlerId,
17215    ) {
17216        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
17217            return;
17218        }
17219        let input_ref = record.input_ref();
17220        let seen = self
17221            .recent_compat_owner_input_ref_sets
17222            .entry(owner.clone())
17223            .or_default();
17224        if !seen.insert(input_ref) {
17225            return;
17226        }
17227        let recent = self
17228            .recent_compat_owner_input_refs
17229            .entry(owner.clone())
17230            .or_default();
17231        recent.push_back(input_ref);
17232        while recent.len() > self.config.reconnect.dedupe_window {
17233            if let Some(evicted) = recent.pop_front() {
17234                seen.remove(&evicted);
17235            }
17236        }
17237    }
17238
17239    fn enqueue_owner_record(
17240        &mut self,
17241        record: ReactiveInputRecord<N>,
17242        owner: SubscriberOwnerEpoch,
17243    ) {
17244        self.enqueue_owner_record_for_owners(record, vec![owner]);
17245    }
17246
17247    fn enqueue_owner_record_for_owners(
17248        &mut self,
17249        record: ReactiveInputRecord<N>,
17250        owners: Vec<SubscriberOwnerEpoch>,
17251    ) {
17252        self.enqueue_owner_record_for_owners_inner(record, owners, true);
17253    }
17254
17255    fn enqueue_owner_record_for_owners_unmerged(
17256        &mut self,
17257        record: ReactiveInputRecord<N>,
17258        owners: Vec<SubscriberOwnerEpoch>,
17259    ) {
17260        self.enqueue_owner_record_for_owners_inner(record, owners, false);
17261    }
17262
17263    fn enqueue_owner_record_for_owners_inner(
17264        &mut self,
17265        record: ReactiveInputRecord<N>,
17266        owners: Vec<SubscriberOwnerEpoch>,
17267        merge_pending: bool,
17268    ) {
17269        let record = self.with_chain_id(record);
17270        let owners = self.filter_recent_owner_duplicates(&record, owners);
17271        if owners.is_empty() {
17272            return;
17273        }
17274        if merge_pending
17275            && should_dedupe_record(&record)
17276            && self.config.reconnect.dedupe_window != 0
17277        {
17278            let input_ref = record.input_ref();
17279            if let Some(pending) = self
17280                .pending_records
17281                .iter_mut()
17282                .rev()
17283                .find(|pending| pending.record.input_ref() == input_ref)
17284            {
17285                let pending_owners = match &mut pending.scope {
17286                    SubscriberInputScope::Canonical { owners }
17287                    | SubscriberInputScope::CanonicalResidual { owners, .. }
17288                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
17289                    SubscriberInputScope::OwnerOnlyHandlers { .. }
17290                    | SubscriberInputScope::Preconfirmed => None,
17291                };
17292                if let Some(pending_owners) = pending_owners {
17293                    for owner in owners {
17294                        if !pending_owners.contains(&owner) {
17295                            pending_owners.push(owner);
17296                        }
17297                    }
17298                    return;
17299                }
17300            }
17301        }
17302        self.push_pending_record(SubscriberInputRecord {
17303            record,
17304            scope: SubscriberInputScope::OwnerOnly { owners },
17305        });
17306    }
17307
17308    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
17309        if self.pending_record_count() >= self.config.max_pending_records {
17310            self.note_resource_error(format!(
17311                "pending record queues reached the configured limit of {}",
17312                self.config.max_pending_records
17313            ));
17314            return;
17315        }
17316        self.pending_records.push_back(record);
17317    }
17318
17319    fn ensure_pending_record_capacity(
17320        &mut self,
17321        additional: usize,
17322        operation: &str,
17323    ) -> Result<(), SubscriberError> {
17324        let required = self.pending_record_count().saturating_add(additional);
17325        if required > self.config.max_pending_records {
17326            self.note_resource_error(format!(
17327                "{operation} require {required} pending records, above the configured limit of {}",
17328                self.config.max_pending_records
17329            ));
17330            return self.check_resource_error();
17331        }
17332        Ok(())
17333    }
17334
17335    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
17336        if self.pending_record_count() >= self.config.max_pending_records {
17337            self.note_resource_error(format!(
17338                "pending record queues reached the configured limit of {}",
17339                self.config.max_pending_records
17340            ));
17341            return;
17342        }
17343        self.pending_reconcile_owner_records.push_back(record);
17344    }
17345
17346    fn pending_record_count(&self) -> usize {
17347        self.pending_records
17348            .len()
17349            .saturating_add(self.pending_reconcile_owner_records.len())
17350    }
17351
17352    fn note_resource_error(&mut self, message: String) {
17353        if self.resource_error.is_none() {
17354            self.resource_error = Some(message);
17355        }
17356    }
17357
17358    fn check_resource_error(&self) -> Result<(), SubscriberError> {
17359        match &self.resource_error {
17360            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
17361            None => Ok(()),
17362        }
17363    }
17364
17365    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
17366        record.context.chain_id = self.chain_id;
17367        if self.config.verify_log_block_context
17368            && let ReactiveInput::Log(log) = &record.input
17369            && !log.removed
17370            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
17371            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
17372        {
17373            record.context.block = Some(verified);
17374            record.context.chain_status = ChainStatus::Included {
17375                block: verified,
17376                confirmations: 0,
17377            };
17378        }
17379        record
17380    }
17381
17382    fn staged_owners_for_record(
17383        &self,
17384        record: &ReactiveInputRecord<N>,
17385    ) -> Vec<SubscriberOwnerEpoch> {
17386        self.owned_interests
17387            .iter()
17388            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
17389            .filter(|entry| {
17390                entry
17391                    .interests
17392                    .iter()
17393                    .any(|interest| interest_matches(interest, &record.input))
17394            })
17395            .filter_map(|entry| entry.epoch.clone())
17396            .collect()
17397    }
17398
17399    fn filter_recent_owner_duplicates(
17400        &mut self,
17401        record: &ReactiveInputRecord<N>,
17402        owners: Vec<SubscriberOwnerEpoch>,
17403    ) -> Vec<SubscriberOwnerEpoch> {
17404        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
17405            return owners;
17406        }
17407        let input_ref = record.input_ref();
17408        let window = self.config.reconnect.dedupe_window;
17409        owners
17410            .into_iter()
17411            .filter(|owner| {
17412                let seen = self
17413                    .recent_owner_input_ref_sets
17414                    .entry(owner.clone())
17415                    .or_default();
17416                if !seen.insert(input_ref) {
17417                    return false;
17418                }
17419                let recent = self
17420                    .recent_owner_input_refs
17421                    .entry(owner.clone())
17422                    .or_default();
17423                recent.push_back(input_ref);
17424                while recent.len() > window {
17425                    if let Some(evicted) = recent.pop_front() {
17426                        seen.remove(&evicted);
17427                    }
17428                }
17429                true
17430            })
17431            .collect()
17432    }
17433
17434    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
17435        if !should_dedupe_record(record) {
17436            return false;
17437        }
17438        self.recent_input_ref_set.contains(&record.input_ref())
17439    }
17440
17441    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
17442        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
17443            return;
17444        }
17445
17446        let input_ref = record.input_ref();
17447        if !self.recent_input_ref_set.insert(input_ref) {
17448            return;
17449        }
17450        self.recent_input_refs.push_back(input_ref);
17451
17452        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
17453            if let Some(evicted) = self.recent_input_refs.pop_front() {
17454                self.recent_input_ref_set.remove(&evicted);
17455            }
17456        }
17457    }
17458}
17459
17460fn stream_with_termination<N, S>(
17461    stream: S,
17462    source: SubscriberStreamSource,
17463) -> BoxStream<'static, SubscriberEvent<N>>
17464where
17465    N: Network + 'static,
17466    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
17467{
17468    stream
17469        .chain(stream::once(async move {
17470            SubscriberEvent::StreamTerminated(source)
17471        }))
17472        .boxed()
17473}
17474
17475fn flashblock_reconnect_future<N>(
17476    provider: RootProvider<N>,
17477    source: SubscriberStreamSource,
17478    channel_size: usize,
17479    reconnect: SubscriberReconnectConfig,
17480    first_delay: Duration,
17481    flashblock_poll_interval: Duration,
17482) -> FlashblockReconnectFuture<N>
17483where
17484    N: Network + 'static,
17485{
17486    Box::pin(async move {
17487        if !reconnect.enabled {
17488            let error = SubscriberError::Provider(format!(
17489                "Alloy subscriber {} stream terminated and reconnect is disabled",
17490                source.label()
17491            ));
17492            return (source, Err(error));
17493        }
17494
17495        let mut attempts = 0_usize;
17496        let mut delay = first_delay;
17497        let mut retry_delay = reconnect.retry_delay;
17498        loop {
17499            attempts = attempts.saturating_add(1);
17500            if !delay.is_zero() {
17501                tokio::time::sleep(delay).await;
17502            }
17503            match connect_flashblock_source_once(
17504                &provider,
17505                source.clone(),
17506                channel_size,
17507                flashblock_poll_interval,
17508            )
17509            .await
17510            {
17511                Ok(stream) => return (source, Ok(stream)),
17512                Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
17513                    return (
17514                        source.clone(),
17515                        Err(SubscriberError::Provider(format!(
17516                            "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
17517                            source.label()
17518                        ))),
17519                    );
17520                }
17521                Err(error) => {
17522                    tracing::warn!(
17523                        stream = source.label(),
17524                        attempts,
17525                        error = %error,
17526                        "Flashblocks reconnect attempt failed"
17527                    );
17528                    delay = retry_delay;
17529                    retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
17530                }
17531            }
17532        }
17533    })
17534}
17535
17536async fn connect_flashblock_source_once<N>(
17537    provider: &RootProvider<N>,
17538    source: SubscriberStreamSource,
17539    channel_size: usize,
17540    flashblock_poll_interval: Duration,
17541) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
17542where
17543    N: Network + 'static,
17544{
17545    #[cfg(not(feature = "reactive-ws"))]
17546    let _ = provider;
17547
17548    match source {
17549        SubscriberStreamSource::BasePendingLog { id, filter } => {
17550            #[cfg(feature = "reactive-ws")]
17551            {
17552                let source = SubscriberStreamSource::BasePendingLog {
17553                    id,
17554                    filter: filter.clone(),
17555                };
17556                let params = base_pending_log_filter(&filter)?;
17557                let stream = provider
17558                    .subscribe::<_, Log>(("pendingLogs", params))
17559                    .channel_size(channel_size.max(1))
17560                    .await
17561                    .map_err(provider_error)?
17562                    .into_stream()
17563                    .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
17564                Ok(stream_with_termination(stream, source))
17565            }
17566            #[cfg(not(feature = "reactive-ws"))]
17567            {
17568                let _ = (id, filter, channel_size);
17569                Err(SubscriberError::Unsupported(
17570                    "Base Flashblocks require the reactive-ws feature",
17571                ))
17572            }
17573        }
17574        SubscriberStreamSource::BaseFlashblocks => {
17575            #[cfg(feature = "reactive-ws")]
17576            {
17577                let stream = provider
17578                    .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
17579                    .channel_size(channel_size.max(1))
17580                    .await
17581                    .map_err(provider_error)?
17582                    .into_stream()
17583                    .map(SubscriberEvent::BaseFlashblock);
17584                Ok(stream_with_termination(
17585                    stream,
17586                    SubscriberStreamSource::BaseFlashblocks,
17587                ))
17588            }
17589            #[cfg(not(feature = "reactive-ws"))]
17590            {
17591                let _ = channel_size;
17592                Err(SubscriberError::Unsupported(
17593                    "Base Flashblocks require the reactive-ws feature",
17594                ))
17595            }
17596        }
17597        SubscriberStreamSource::OpPendingFlashblocks => {
17598            let first_tick = tokio::time::Instant::now();
17599            let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
17600            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
17601            let stream = stream::unfold(interval, |mut interval| async move {
17602                interval.tick().await;
17603                Some((SubscriberEvent::OpFlashblockTick, interval))
17604            });
17605            Ok(stream_with_termination(
17606                stream,
17607                SubscriberStreamSource::OpPendingFlashblocks,
17608            ))
17609        }
17610        source => Err(SubscriberError::InvalidConfig(match source {
17611            SubscriberStreamSource::PubSubLog { .. }
17612            | SubscriberStreamSource::CanonicalHeadPolling
17613            | SubscriberStreamSource::PubSubPendingHashes
17614            | SubscriberStreamSource::PubSubBlockHeaders
17615            | SubscriberStreamSource::PollingLog { .. }
17616            | SubscriberStreamSource::PollingPendingHashes => {
17617                "Flashblocks reconnect received a canonical source"
17618            }
17619            SubscriberStreamSource::BasePendingLog { .. }
17620            | SubscriberStreamSource::BaseFlashblocks
17621            | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
17622        })),
17623    }
17624}
17625
17626fn aggregate_interests<N: Network>(
17627    base: &[ReactiveInterest<N>],
17628    owned: &[OwnedSubscriberInterests<N>],
17629) -> Vec<ReactiveInterest<N>> {
17630    base.iter()
17631        .cloned()
17632        .chain(
17633            owned
17634                .iter()
17635                .flat_map(|entry| entry.interests.iter().cloned()),
17636        )
17637        .collect()
17638}
17639
17640fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
17641    SubscriberError::Provider(format!(
17642        "Alloy subscriber {} stream terminated before the subscriber was stopped",
17643        source.label()
17644    ))
17645}
17646
17647fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
17648    config
17649        .max_attempts
17650        .is_some_and(|max_attempts| attempts >= max_attempts)
17651}
17652
17653fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
17654    if current.is_zero() {
17655        return current;
17656    }
17657    current.checked_mul(2).unwrap_or(max).min(max)
17658}
17659
17660fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
17661    match &record.input {
17662        ReactiveInput::Log(log) => {
17663            is_canonical_status(&record.context.chain_status) && !log.removed
17664        }
17665        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
17666        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
17667    }
17668}
17669
17670#[cfg(test)]
17671mod subscriber_helper_tests {
17672    use super::*;
17673    use alloy_provider::ProviderBuilder;
17674    use alloy_transport::mock::Asserter;
17675
17676    fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
17677        BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
17678            payload_id: FixedBytes::repeat_byte(0x11),
17679            index: 0,
17680            base: Some(BaseFlashblockBase {
17681                parent_hash: B256::repeat_byte(100),
17682                block_number: 101,
17683                timestamp: 1_700_000_101,
17684                gas_limit: Some(30_000_000),
17685                base_fee_per_gas: Some(7),
17686                beneficiary: Some(Address::repeat_byte(0xcb)),
17687                prevrandao: Some(B256::repeat_byte(0x77)),
17688            }),
17689            diff: BaseFlashblockDiff {
17690                state_root,
17691                block_hash: B256::ZERO,
17692                transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
17693                transactions_root: None,
17694            },
17695            metadata: None,
17696        })
17697    }
17698
17699    #[test]
17700    fn duplicate_flashblock_transaction_membership_is_rejected() {
17701        let transaction = format!("{:#x}", B256::repeat_byte(0x41));
17702        let transactions = vec![
17703            serde_json::Value::String(transaction.clone()),
17704            serde_json::Value::String(transaction),
17705        ];
17706        assert!(matches!(
17707            flashblock_transaction_hashes(&transactions),
17708            Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
17709        ));
17710    }
17711
17712    #[test]
17713    fn conflicting_duplicate_indexed_flashblock_is_rejected() {
17714        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17715        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17716            provider,
17717            SubscriberMode::PubSub,
17718            SubscriberConfig::default(),
17719        )
17720        .with_provider_ref(ProviderRef::new("base-paid", 7));
17721        subscriber.chain_id = Some(8_453);
17722
17723        subscriber
17724            .accept_base_flashblock(indexed_flashblock(
17725                B256::repeat_byte(0x41),
17726                B256::repeat_byte(0xa1),
17727            ))
17728            .expect("first indexed preview");
17729        assert!(matches!(
17730            subscriber.accept_base_flashblock(indexed_flashblock(
17731                B256::repeat_byte(0x42),
17732                B256::repeat_byte(0xa2),
17733            )),
17734            Err(SubscriberError::Provider(ref message))
17735                if message.contains("conflicting duplicate")
17736        ));
17737    }
17738
17739    #[tokio::test]
17740    async fn duplicate_index_with_changed_commitment_is_rejected() {
17741        let transaction = B256::repeat_byte(0x41);
17742        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17743        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17744            provider,
17745            SubscriberMode::PubSub,
17746            SubscriberConfig::default(),
17747        )
17748        .with_provider_ref(ProviderRef::new("base-paid", 7));
17749        subscriber.chain_id = Some(8_453);
17750
17751        subscriber
17752            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
17753                transaction,
17754                B256::repeat_byte(0xa1),
17755            )))
17756            .await
17757            .expect("first indexed preview");
17758
17759        let BaseFlashblockWirePayload::Indexed(mut conflicting) =
17760            indexed_flashblock(transaction, B256::repeat_byte(0xa1))
17761        else {
17762            unreachable!()
17763        };
17764        conflicting.diff.state_root = B256::repeat_byte(0xbb);
17765        assert!(matches!(
17766            subscriber
17767                .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
17768                    BaseFlashblockWirePayload::Indexed(conflicting),
17769                ))
17770                .await,
17771            Err(SubscriberError::Provider(ref message))
17772                if message.contains("conflicting duplicate indexed Flashblock content")
17773        ));
17774    }
17775
17776    #[tokio::test]
17777    async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
17778        let transaction_a = B256::repeat_byte(0x41);
17779        let transaction_b = B256::repeat_byte(0x42);
17780        let transaction_c = B256::repeat_byte(0x43);
17781        let transaction_d = B256::repeat_byte(0x44);
17782        let asserter = Asserter::new();
17783        asserter.push_success(&100_u64);
17784        let pending = rpc_block(101, B256::ZERO).with_transactions(
17785            alloy_network::primitives::BlockTransactions::Hashes(vec![
17786                transaction_a,
17787                transaction_b,
17788                transaction_c,
17789            ]),
17790        );
17791        asserter.push_success(&Some(pending));
17792        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17793        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17794            provider,
17795            SubscriberMode::PubSub,
17796            SubscriberConfig::default(),
17797        )
17798        .with_provider_ref(ProviderRef::new("base-paid", 7));
17799        subscriber.chain_id = Some(8_453);
17800
17801        subscriber
17802            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
17803                transaction_a,
17804                B256::repeat_byte(0xa1),
17805            )))
17806            .await
17807            .expect("index zero preview");
17808        let BaseFlashblockWirePayload::Indexed(mut gap) =
17809            indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
17810        else {
17811            unreachable!()
17812        };
17813        gap.index = 2;
17814        gap.base = None;
17815        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
17816        subscriber
17817            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
17818                BaseFlashblockWirePayload::Indexed(gap),
17819            ))
17820            .await
17821            .expect("the missing index is recovered from pending state");
17822
17823        let BaseFlashblockWirePayload::Indexed(mut next) =
17824            indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
17825        else {
17826            unreachable!()
17827        };
17828        next.index = 3;
17829        next.base = None;
17830        next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
17831        let (next, recover) = subscriber
17832            .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
17833            .expect("the next diff extends the recovered cumulative set");
17834        assert!(!recover);
17835        assert_eq!(
17836            next.transaction_hashes,
17837            vec![transaction_a, transaction_b, transaction_c, transaction_d]
17838        );
17839    }
17840
17841    #[tokio::test]
17842    async fn unrecoverable_indexed_gap_revokes_the_generation() {
17843        let asserter = Asserter::new();
17844        asserter.push_success(&100_u64);
17845        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
17846        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
17847        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17848            provider,
17849            SubscriberMode::PubSub,
17850            SubscriberConfig {
17851                preconfirmations: PreconfirmationMode::Preferred,
17852                ..SubscriberConfig::default()
17853            },
17854        )
17855        .with_provider_ref(ProviderRef::new("base-paid", 7));
17856        subscriber.chain_id = Some(8_453);
17857
17858        subscriber
17859            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
17860                B256::repeat_byte(0x41),
17861                B256::repeat_byte(0xa1),
17862            )))
17863            .await
17864            .expect("index zero preview");
17865        let BaseFlashblockWirePayload::Indexed(mut gap) =
17866            indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
17867        else {
17868            unreachable!()
17869        };
17870        gap.index = 2;
17871        gap.base = None;
17872        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
17873        let event = subscriber
17874            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
17875                BaseFlashblockWirePayload::Indexed(gap),
17876            ))
17877            .await
17878            .expect("preferred mode fails closed without pending recovery")
17879            .expect("generation invalidation is observable");
17880        assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
17881        assert!(subscriber.latest_preconfirmation.is_none());
17882        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
17883    }
17884
17885    #[test]
17886    fn base_flashblock_wire_decodes_cumulative_block_shape() {
17887        let payload: BaseFlashblockWirePayload = serde_json::from_str(
17888            r#"{
17889                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
17890                "number":"0x2ef403b",
17891                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
17892                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
17893                "timestamp":"0x6a68dd59",
17894                "transactions":[]
17895            }"#,
17896        )
17897        .expect("decode current Base newFlashblocks shape");
17898        let BaseFlashblockWirePayload::Block(payload) = payload else {
17899            panic!("expected cumulative block-shaped payload")
17900        };
17901        assert_eq!(payload.number, 49_233_979);
17902        assert_eq!(payload.timestamp, 1_785_257_305);
17903        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
17904        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
17905        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
17906    }
17907
17908    #[tokio::test]
17909    async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
17910        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17911        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17912            provider,
17913            SubscriberMode::PubSub,
17914            SubscriberConfig::default(),
17915        )
17916        .with_provider_ref(ProviderRef::new("base-paid", 7));
17917        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
17918            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
17919            local_matcher: None,
17920            route_key: None,
17921        })];
17922        subscriber.interests = subscriber.base_interests.clone();
17923
17924        let first: BaseFlashblockWirePayload = serde_json::from_str(
17925            r#"{
17926                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
17927                "number":"0x65",
17928                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
17929                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
17930                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
17931                "timestamp":"0x6553f165",
17932                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
17933            }"#,
17934        )
17935        .expect("decode first cumulative preview");
17936        subscriber
17937            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(first))
17938            .await
17939            .expect("first preview is accepted");
17940
17941        let mut second_log = rpc_log(false);
17942        second_log.block_hash = Some(B256::ZERO);
17943        second_log.block_number = Some(102);
17944        second_log.block_timestamp = Some(1_700_000_102);
17945        second_log.transaction_hash = Some(B256::repeat_byte(0x42));
17946        second_log.transaction_index = Some(0);
17947        second_log.log_index = Some(0);
17948
17949        let before_preview = subscriber
17950            .normalize_flashblock_event(SubscriberEvent::BasePendingLog {
17951                source_id: 0,
17952                log: second_log,
17953            })
17954            .await
17955            .expect("a zero-hash log for the next block must be buffered");
17956        assert!(before_preview.is_none());
17957
17958        let second: BaseFlashblockWirePayload = serde_json::from_str(
17959            r#"{
17960                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
17961                "number":"0x66",
17962                "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
17963                "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
17964                "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
17965                "timestamp":"0x6553f166",
17966                "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
17967            }"#,
17968        )
17969        .expect("decode second cumulative preview");
17970        let event = subscriber
17971            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(second))
17972            .await
17973            .expect("second preview is accepted")
17974            .expect("the matching buffered log is released");
17975        let SubscriberEvent::PreconfirmedLogs { flashblock, logs } = event else {
17976            panic!("expected a preconfirmed log batch")
17977        };
17978        assert_eq!(flashblock.block_number, 102);
17979        assert_ne!(flashblock.content_hash, B256::ZERO);
17980        assert_eq!(flashblock.partial_block_hash, None);
17981        assert_eq!(logs.len(), 1);
17982        assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
17983        assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
17984    }
17985
17986    #[test]
17987    fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
17988        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
17989        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
17990            provider,
17991            SubscriberMode::PubSub,
17992            SubscriberConfig {
17993                preconfirmations: PreconfirmationMode::Required,
17994                ..SubscriberConfig::default()
17995            },
17996        )
17997        .with_provider_ref(ProviderRef::new("base-paid", 7));
17998        subscriber.chain_id = Some(8_453);
17999        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
18000
18001        let sources = subscriber.pubsub_stream_sources();
18002        assert!(
18003            sources
18004                .iter()
18005                .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
18006        );
18007        assert!(
18008            !sources
18009                .iter()
18010                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
18011        );
18012    }
18013
18014    #[tokio::test]
18015    async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
18016        let asserter = Asserter::new();
18017        let certified = rpc_block(101, B256::repeat_byte(0x65));
18018        asserter.push_success(&Some(certified.clone()));
18019        asserter.push_success(&Some(certified));
18020        asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
18021        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18022        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18023            provider,
18024            SubscriberMode::PubSub,
18025            SubscriberConfig::default(),
18026        );
18027
18028        assert!(matches!(
18029            subscriber
18030                .fetch_certified_canonical_head()
18031                .await
18032                .expect("first certified head"),
18033            Some(SubscriberEvent::BlockHeader(_))
18034        ));
18035        assert!(
18036            subscriber
18037                .fetch_certified_canonical_head()
18038                .await
18039                .expect("duplicate certified head")
18040                .is_none()
18041        );
18042        assert!(matches!(
18043            subscriber.fetch_certified_canonical_head().await,
18044            Err(SubscriberError::Provider(ref message))
18045                if message.contains("placeholder hash")
18046        ));
18047    }
18048
18049    #[tokio::test]
18050    async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
18051        let asserter = Asserter::new();
18052        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
18053        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18054        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18055            provider,
18056            SubscriberMode::PubSub,
18057            SubscriberConfig {
18058                preconfirmations: PreconfirmationMode::Required,
18059                ..SubscriberConfig::default()
18060            },
18061        );
18062        subscriber.chain_id = Some(10);
18063        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
18064
18065        let event = subscriber
18066            .fetch_certified_canonical_head()
18067            .await
18068            .expect("OP pending parent can be certified")
18069            .expect("the first certified parent is emitted");
18070        let SubscriberEvent::BlockHeader(header) = event else {
18071            panic!("expected a certified canonical block header")
18072        };
18073        assert_eq!(header.number(), 100);
18074        assert_eq!(header.hash, B256::repeat_byte(0x64));
18075        assert_eq!(
18076            subscriber
18077                .flashblocks_rpc_metrics()
18078                .pending_block_requests(),
18079            1
18080        );
18081        assert_eq!(
18082            subscriber
18083                .flashblocks_rpc_metrics()
18084                .canonical_head_requests(),
18085            1
18086        );
18087        assert!(asserter.read_q().is_empty());
18088    }
18089
18090    #[test]
18091    fn optimism_uses_one_bounded_pending_state_stream() {
18092        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18093        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18094            provider,
18095            SubscriberMode::PubSub,
18096            SubscriberConfig {
18097                preconfirmations: PreconfirmationMode::Required,
18098                ..SubscriberConfig::default()
18099            },
18100        )
18101        .with_provider_ref(ProviderRef::new("op-paid", 11));
18102        subscriber.chain_id = Some(10);
18103        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
18104            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
18105            local_matcher: None,
18106            route_key: None,
18107        })];
18108        subscriber.interests = subscriber.base_interests.clone();
18109
18110        let sources = subscriber.pubsub_stream_sources();
18111        assert_eq!(
18112            sources
18113                .iter()
18114                .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
18115                .count(),
18116            1
18117        );
18118        assert!(sources.iter().all(|source| !matches!(
18119            source,
18120            SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
18121        )));
18122    }
18123
18124    #[test]
18125    fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
18126        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18127        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18128            provider,
18129            SubscriberMode::PubSub,
18130            SubscriberConfig {
18131                preconfirmations: PreconfirmationMode::Required,
18132                ..SubscriberConfig::default()
18133            },
18134        );
18135        subscriber.chain_id = Some(10);
18136        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18137        subscriber.interests = subscriber.base_interests.clone();
18138
18139        // At 250 ms, the sampler reserves 4 * (exact parent + pending block +
18140        // one filtered log request) = 12 methods. The remaining 28 exact
18141        // receipt methods stay below the configured 40-method ceiling.
18142        assert_eq!(
18143            subscriber.pending_receipt_requests_per_second_capacity(),
18144            28
18145        );
18146        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
18147
18148        subscriber
18149            .interests
18150            .push(ReactiveInterest::Blocks(BlockInterest::default()));
18151        assert_eq!(
18152            subscriber.pending_receipt_requests_per_second_capacity(),
18153            24
18154        );
18155        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
18156        subscriber.interests.pop();
18157
18158        for _ in 0..4 {
18159            assert!(subscriber.reserve_flashblock_rpc_methods(3));
18160            assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
18161            assert!(subscriber.reserve_flashblock_rpc_methods(7));
18162        }
18163        assert!(!subscriber.reserve_flashblock_rpc_methods(1));
18164        subscriber.reset_flashblock_tracking();
18165        assert!(
18166            !subscriber.reserve_flashblock_rpc_methods(1),
18167            "a reconnect must not reset an endpoint's rolling quota window"
18168        );
18169    }
18170
18171    #[test]
18172    fn flashblocks_config_rejects_a_zero_rpc_budget() {
18173        let config = SubscriberConfig {
18174            preconfirmations: PreconfirmationMode::Required,
18175            max_flashblock_rpc_requests_per_second: 0,
18176            ..SubscriberConfig::default()
18177        };
18178
18179        assert!(matches!(
18180            validate_subscriber_config(&config),
18181            Err(SubscriberError::InvalidConfig(
18182                "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
18183            ))
18184        ));
18185    }
18186
18187    #[tokio::test]
18188    async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
18189        let asserter = Asserter::new();
18190        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
18191        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18192        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18193            provider,
18194            SubscriberMode::PubSub,
18195            SubscriberConfig {
18196                preconfirmations: PreconfirmationMode::Required,
18197                // Four ticks reserve three fixed methods each. Three remaining
18198                // methods cannot fund even one receipt on every tick.
18199                max_flashblock_rpc_requests_per_second: 15,
18200                ..SubscriberConfig::default()
18201            },
18202        )
18203        .with_provider_ref(ProviderRef::new("op-paid", 12));
18204        subscriber.chain_id = Some(10);
18205        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18206        subscriber.interests = subscriber.base_interests.clone();
18207        let desired = subscriber.pubsub_stream_sources();
18208        let mut streams = SubscriberStreams::new();
18209        for source in desired {
18210            streams.push(source, stream::pending().boxed());
18211        }
18212        subscriber.state = AlloySubscriberState::Active(streams);
18213        subscriber.sources_dirty = false;
18214        assert!(matches!(
18215            subscriber.establish_flashblocks_preflight(10).await,
18216            Err(SubscriberError::InvalidConfig(message))
18217                if message.contains("leaves no capacity for OP transaction receipts")
18218        ));
18219        assert!(asserter.read_q().is_empty());
18220    }
18221
18222    #[cfg(feature = "reactive-ws")]
18223    #[tokio::test]
18224    async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
18225        let asserter = Asserter::new();
18226        asserter.push_success(&serde_json::json!({"flashblocks": true}));
18227        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18228        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18229            provider,
18230            SubscriberMode::PubSub,
18231            SubscriberConfig {
18232                preconfirmations: PreconfirmationMode::Required,
18233                ..SubscriberConfig::default()
18234            },
18235        )
18236        .with_provider_ref(ProviderRef::new("base-paid", 7));
18237        subscriber.chain_id = Some(8_453);
18238        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18239        subscriber.interests = subscriber.base_interests.clone();
18240        let desired = subscriber.pubsub_stream_sources();
18241        let mut streams = SubscriberStreams::new();
18242        for source in desired {
18243            streams.push(source, stream::pending().boxed());
18244        }
18245        subscriber.state = AlloySubscriberState::Active(streams);
18246        subscriber.sources_dirty = false;
18247
18248        let preflight = subscriber
18249            .establish_flashblocks_preflight(8_453)
18250            .await
18251            .expect("preflight succeeds");
18252
18253        assert_eq!(preflight.chain_id(), 8_453);
18254        assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
18255        assert_eq!(
18256            preflight.delivery(),
18257            FlashblocksDelivery::NativeSubscriptions
18258        );
18259        assert_eq!(preflight.pending_log_subscriptions(), 1);
18260        assert_eq!(preflight.pending_log_filters(), 1);
18261        assert_eq!(
18262            preflight.advertised_capabilities(),
18263            Some(&serde_json::json!({"flashblocks": true}))
18264        );
18265    }
18266
18267    #[tokio::test]
18268    async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
18269        let asserter = Asserter::new();
18270        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
18271        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
18272        asserter.push_success(&Vec::<Log>::new());
18273        asserter.push_success(&serde_json::json!([]));
18274        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18275        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18276            provider,
18277            SubscriberMode::PubSub,
18278            SubscriberConfig {
18279                preconfirmations: PreconfirmationMode::Required,
18280                ..SubscriberConfig::default()
18281            },
18282        )
18283        .with_provider_ref(ProviderRef::new("op-paid", 12));
18284        subscriber.chain_id = Some(10);
18285        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18286        subscriber.interests = subscriber.base_interests.clone();
18287        let desired = subscriber.pubsub_stream_sources();
18288        let mut streams = SubscriberStreams::new();
18289        for source in desired {
18290            streams.push(source, stream::pending().boxed());
18291        }
18292        subscriber.state = AlloySubscriberState::Active(streams);
18293        subscriber.sources_dirty = false;
18294
18295        let preflight = subscriber
18296            .establish_flashblocks_preflight(10)
18297            .await
18298            .expect("Optimism pending-state preflight succeeds");
18299
18300        assert_eq!(preflight.chain_id(), 10);
18301        assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
18302        assert_eq!(
18303            preflight.delivery(),
18304            FlashblocksDelivery::PendingStatePolling
18305        );
18306        assert_eq!(preflight.pending_log_subscriptions(), 0);
18307        assert_eq!(preflight.pending_log_filters(), 1);
18308        assert_eq!(
18309            preflight.advertised_capabilities(),
18310            Some(&serde_json::json!(["flashblocksv1"]))
18311        );
18312        assert!(asserter.read_q().is_empty());
18313    }
18314
18315    #[test]
18316    fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
18317        let transaction_hash = B256::repeat_byte(0x7e);
18318        let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
18319            .expect("serialize pending block fixture");
18320        value["transactions"] = serde_json::json!([{
18321            "type": "0x7e",
18322            "hash": transaction_hash,
18323            "sourceHash": B256::repeat_byte(0x11),
18324            "from": Address::repeat_byte(0x22),
18325            "to": Address::repeat_byte(0x33)
18326        }]);
18327
18328        let block = normalize_op_pending_block::<Ethereum>(value)
18329            .expect("OP-specific transaction bodies are reduced to hashes");
18330
18331        assert_eq!(
18332            block.transactions().as_hashes(),
18333            Some(&[transaction_hash][..])
18334        );
18335    }
18336
18337    #[tokio::test]
18338    async fn optimism_sampler_does_not_retry_malformed_pending_content() {
18339        let asserter = Asserter::new();
18340        let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
18341            .expect("serialize pending block fixture");
18342        pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
18343        asserter.push_success(&Some(pending));
18344        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18345        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18346            provider,
18347            SubscriberMode::PubSub,
18348            SubscriberConfig {
18349                preconfirmations: PreconfirmationMode::Required,
18350                ..SubscriberConfig::default()
18351            },
18352        )
18353        .with_provider_ref(ProviderRef::new("op-paid", 12));
18354        subscriber.chain_id = Some(10);
18355
18356        let error = match subscriber
18357            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18358            .await
18359        {
18360            Err(error) => error,
18361            Ok(_) => panic!("malformed provider content must fail immediately"),
18362        };
18363        assert!(
18364            error
18365                .to_string()
18366                .contains("transaction is missing its hash")
18367        );
18368        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
18369        assert!(asserter.read_q().is_empty());
18370    }
18371
18372    #[tokio::test]
18373    async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
18374        let asserter = Asserter::new();
18375        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
18376        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18377        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18378            provider,
18379            SubscriberMode::PubSub,
18380            SubscriberConfig {
18381                preconfirmations: PreconfirmationMode::Required,
18382                ..SubscriberConfig::default()
18383            },
18384        )
18385        .with_provider_ref(ProviderRef::new("op-paid", 12));
18386        subscriber.chain_id = Some(10);
18387
18388        assert!(
18389            subscriber
18390                .fetch_pending_flashblock(None)
18391                .await
18392                .expect("the exact parent certifies the pending payload")
18393                .is_some()
18394        );
18395    }
18396
18397    #[tokio::test]
18398    async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
18399        let asserter = Asserter::new();
18400        asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
18401        asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
18402        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18403        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18404            provider,
18405            SubscriberMode::PubSub,
18406            SubscriberConfig {
18407                preconfirmations: PreconfirmationMode::Required,
18408                ..SubscriberConfig::default()
18409            },
18410        )
18411        .with_provider_ref(ProviderRef::new("op-paid", 12));
18412        subscriber.chain_id = Some(10);
18413
18414        assert!(matches!(
18415            subscriber.fetch_pending_flashblock(None).await,
18416            Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
18417                ref message
18418            ))) if message.contains("does not extend its exact certified parent")
18419        ));
18420        assert!(asserter.read_q().is_empty());
18421    }
18422
18423    #[tokio::test]
18424    async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
18425        let asserter = Asserter::new();
18426        let pending = rpc_block(101, B256::ZERO);
18427        queue_op_pending(&asserter, pending.clone());
18428        asserter.push_success(&Vec::<Log>::new());
18429        queue_op_pending(&asserter, pending);
18430        asserter.push_success(&Vec::<Log>::new());
18431        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18432        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18433            provider,
18434            SubscriberMode::PubSub,
18435            SubscriberConfig {
18436                preconfirmations: PreconfirmationMode::Required,
18437                ..SubscriberConfig::default()
18438            },
18439        )
18440        .with_provider_ref(ProviderRef::new("op-paid", 12));
18441        subscriber.chain_id = Some(10);
18442        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18443        subscriber.interests = subscriber.base_interests.clone();
18444
18445        assert!(
18446            subscriber
18447                .fetch_pending_flashblock(None)
18448                .await
18449                .expect("first cumulative pending view")
18450                .is_some()
18451        );
18452        assert!(
18453            subscriber
18454                .fetch_pending_flashblock(None)
18455                .await
18456                .expect("duplicate cumulative pending view")
18457                .is_none()
18458        );
18459
18460        assert_eq!(
18461            subscriber.flashblocks_rpc_metrics(),
18462            FlashblocksRpcMetrics {
18463                capability_requests: 0,
18464                provider_pair_chain_requests: 0,
18465                canonical_head_requests: 2,
18466                pending_block_requests: 2,
18467                pending_log_requests: 2,
18468                pending_receipt_requests: 0,
18469                pending_receipts_completed: 0,
18470                pending_receipts_unavailable: 0,
18471                failed_requests: 0,
18472                raced_samples: 0,
18473            }
18474        );
18475        assert!(asserter.read_q().is_empty());
18476    }
18477
18478    #[tokio::test]
18479    async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
18480        let asserter = Asserter::new();
18481        let transaction = B256::repeat_byte(0x42);
18482        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18483            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
18484        );
18485        let mut log = rpc_log(false);
18486        log.block_number = Some(101);
18487        log.block_hash = Some(B256::repeat_byte(0xa2));
18488        log.transaction_hash = Some(transaction);
18489        log.transaction_index = Some(0);
18490        log.log_index = Some(0);
18491        queue_op_pending(&asserter, pending.clone());
18492        asserter.push_success(&Vec::<Log>::new());
18493        asserter.push_success(&serde_json::Value::Null);
18494        queue_op_pending(&asserter, pending);
18495        asserter.push_success(&vec![log]);
18496        asserter.push_success(&serde_json::Value::Null);
18497        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18498        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18499            provider,
18500            SubscriberMode::PubSub,
18501            SubscriberConfig {
18502                preconfirmations: PreconfirmationMode::Required,
18503                ..SubscriberConfig::default()
18504            },
18505        )
18506        .with_provider_ref(ProviderRef::new("op-paid", 12));
18507        subscriber.chain_id = Some(10);
18508        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18509        subscriber.interests = subscriber.base_interests.clone();
18510
18511        assert!(matches!(
18512            subscriber
18513                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18514                .await
18515                .expect("first pending view is coherent"),
18516            Some(SubscriberEvent::FlashblockObserved)
18517        ));
18518        assert!(matches!(
18519            subscriber
18520                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18521                .await
18522                .expect("the unchanged view is checked again for lagging logs"),
18523            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
18524        ));
18525        assert!(asserter.read_q().is_empty());
18526    }
18527
18528    #[tokio::test]
18529    async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
18530        let asserter = Asserter::new();
18531        let transaction = B256::repeat_byte(0x42);
18532        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18533            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
18534        );
18535        let mut log = rpc_log(false);
18536        log.block_number = Some(101);
18537        log.block_hash = Some(B256::repeat_byte(0xa2));
18538        log.transaction_hash = Some(transaction);
18539        log.transaction_index = Some(0);
18540        log.log_index = Some(0);
18541        queue_op_pending(&asserter, pending);
18542        asserter.push_success(&Vec::<Log>::new());
18543        asserter.push_success(&serde_json::json!({
18544            "transactionHash": transaction,
18545            "logs": [log]
18546        }));
18547        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18548        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18549            provider,
18550            SubscriberMode::PubSub,
18551            SubscriberConfig {
18552                preconfirmations: PreconfirmationMode::Required,
18553                ..SubscriberConfig::default()
18554            },
18555        )
18556        .with_provider_ref(ProviderRef::new("op-paid", 12));
18557        subscriber.chain_id = Some(10);
18558        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18559        subscriber.interests = subscriber.base_interests.clone();
18560
18561        let event = subscriber
18562            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18563            .await
18564            .expect("pending receipt fallback succeeds");
18565        assert!(matches!(
18566            event,
18567            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
18568        ));
18569        assert_eq!(
18570            subscriber
18571                .flashblocks_rpc_metrics()
18572                .pending_receipt_requests(),
18573            1
18574        );
18575        assert!(asserter.read_q().is_empty());
18576    }
18577
18578    #[tokio::test]
18579    async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
18580        let asserter = Asserter::new();
18581        let transaction_a = B256::repeat_byte(0x41);
18582        let transaction_b = B256::repeat_byte(0x42);
18583        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18584            alloy_network::primitives::BlockTransactions::Hashes(vec![
18585                transaction_a,
18586                transaction_b,
18587            ]),
18588        );
18589        let mut log = rpc_log(false);
18590        log.block_number = Some(101);
18591        log.transaction_hash = Some(transaction_b);
18592        log.transaction_index = Some(1);
18593        queue_op_pending(&asserter, pending.clone());
18594        asserter.push_success(&Vec::<Log>::new());
18595        asserter.push_success(&serde_json::json!({
18596            "transactionHash": transaction_a,
18597            "logs": []
18598        }));
18599        queue_op_pending(&asserter, pending);
18600        asserter.push_success(&Vec::<Log>::new());
18601        asserter.push_success(&serde_json::json!({
18602            "transactionHash": transaction_b,
18603            "logs": [log]
18604        }));
18605        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18606        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18607            provider,
18608            SubscriberMode::PubSub,
18609            SubscriberConfig {
18610                preconfirmations: PreconfirmationMode::Required,
18611                max_pending_transaction_receipts_per_tick: 1,
18612                ..SubscriberConfig::default()
18613            },
18614        )
18615        .with_provider_ref(ProviderRef::new("op-paid", 12));
18616        subscriber.chain_id = Some(10);
18617        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18618        subscriber.interests = subscriber.base_interests.clone();
18619
18620        assert!(matches!(
18621            subscriber
18622                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18623                .await
18624                .expect("the first bounded receipt is hydrated"),
18625            Some(SubscriberEvent::FlashblockObserved)
18626        ));
18627        assert!(matches!(
18628            subscriber
18629                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18630                .await
18631                .expect("the remaining receipt is hydrated on the next tick"),
18632            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
18633        ));
18634        assert_eq!(
18635            subscriber
18636                .flashblocks_rpc_metrics()
18637                .pending_receipt_requests(),
18638            2
18639        );
18640        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
18641        assert!(asserter.read_q().is_empty());
18642    }
18643
18644    #[tokio::test]
18645    async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
18646        let asserter = Asserter::new();
18647        let transaction_a = B256::repeat_byte(0x41);
18648        let transaction_b = B256::repeat_byte(0x42);
18649        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18650            alloy_network::primitives::BlockTransactions::Hashes(vec![
18651                transaction_a,
18652                transaction_b,
18653            ]),
18654        );
18655        let mut log = rpc_log(false);
18656        log.block_number = Some(101);
18657        log.transaction_hash = Some(transaction_b);
18658        log.transaction_index = Some(1);
18659        queue_op_pending(&asserter, pending.clone());
18660        asserter.push_success(&Vec::<Log>::new());
18661        asserter.push_success(&serde_json::Value::Null);
18662        queue_op_pending(&asserter, pending);
18663        asserter.push_success(&Vec::<Log>::new());
18664        asserter.push_success(&serde_json::json!({
18665            "transactionHash": transaction_b,
18666            "logs": [log]
18667        }));
18668        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18669        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18670            provider,
18671            SubscriberMode::PubSub,
18672            SubscriberConfig {
18673                preconfirmations: PreconfirmationMode::Required,
18674                max_pending_transaction_receipts_per_tick: 1,
18675                ..SubscriberConfig::default()
18676            },
18677        )
18678        .with_provider_ref(ProviderRef::new("op-paid", 12));
18679        subscriber.chain_id = Some(10);
18680        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18681        subscriber.interests = subscriber.base_interests.clone();
18682
18683        assert!(matches!(
18684            subscriber
18685                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18686                .await
18687                .expect("the first null receipt remains retryable"),
18688            Some(SubscriberEvent::FlashblockObserved)
18689        ));
18690        assert!(matches!(
18691            subscriber
18692                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18693                .await
18694                .expect("the next unattempted receipt is not starved"),
18695            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
18696        ));
18697        assert!(
18698            subscriber
18699                .preconfirmed_unavailable_receipts
18700                .contains(&transaction_a)
18701        );
18702        assert!(
18703            subscriber
18704                .preconfirmed_receipted_transactions
18705                .contains(&transaction_b)
18706        );
18707        assert!(asserter.read_q().is_empty());
18708    }
18709
18710    #[tokio::test]
18711    async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
18712        let asserter = Asserter::new();
18713        let transaction_a = B256::repeat_byte(0x41);
18714        let transaction_b = B256::repeat_byte(0x42);
18715        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18716            alloy_network::primitives::BlockTransactions::Hashes(vec![
18717                transaction_a,
18718                transaction_b,
18719            ]),
18720        );
18721        let mut log = rpc_log(false);
18722        log.block_number = Some(101);
18723        log.transaction_hash = Some(transaction_b);
18724        log.transaction_index = Some(1);
18725        queue_op_pending(&asserter, pending.clone());
18726        asserter.push_success(&Vec::<Log>::new());
18727        asserter.push_success(&serde_json::json!({
18728            "transactionHash": transaction_a,
18729            "logs": []
18730        }));
18731        asserter.push_failure_msg("receipt temporarily unavailable");
18732        queue_op_pending(&asserter, pending);
18733        asserter.push_success(&Vec::<Log>::new());
18734        asserter.push_success(&serde_json::json!({
18735            "transactionHash": transaction_a,
18736            "logs": []
18737        }));
18738        asserter.push_success(&serde_json::json!({
18739            "transactionHash": transaction_b,
18740            "logs": [log]
18741        }));
18742        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18743        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18744            provider,
18745            SubscriberMode::PubSub,
18746            SubscriberConfig {
18747                preconfirmations: PreconfirmationMode::Required,
18748                max_pending_transaction_receipts_per_tick: 2,
18749                max_consecutive_flashblock_poll_failures: 2,
18750                ..SubscriberConfig::default()
18751            },
18752        )
18753        .with_provider_ref(ProviderRef::new("op-paid", 12));
18754        subscriber.chain_id = Some(10);
18755        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18756        subscriber.interests = subscriber.base_interests.clone();
18757
18758        assert!(
18759            subscriber
18760                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18761                .await
18762                .expect("one failed receipt response remains retryable")
18763                .is_none()
18764        );
18765        assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
18766        assert!(matches!(
18767            subscriber
18768                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18769                .await
18770                .expect("the complete batch is retried transactionally"),
18771            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
18772        ));
18773        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
18774        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
18775        assert_eq!(
18776            subscriber
18777                .flashblocks_rpc_metrics()
18778                .pending_receipt_requests(),
18779            4
18780        );
18781        assert!(asserter.read_q().is_empty());
18782    }
18783
18784    #[tokio::test]
18785    async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
18786        let asserter = Asserter::new();
18787        let sampled_transaction = B256::repeat_byte(0x41);
18788        let advanced_transaction = B256::repeat_byte(0x42);
18789        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18790            alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
18791        );
18792        let mut log = rpc_log(false);
18793        log.block_number = Some(101);
18794        log.transaction_hash = Some(advanced_transaction);
18795        queue_op_pending(&asserter, pending);
18796        asserter.push_success(&Vec::<Log>::new());
18797        asserter.push_success(&serde_json::json!({
18798            "transactionHash": advanced_transaction,
18799            "logs": [log]
18800        }));
18801        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18802        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18803            provider,
18804            SubscriberMode::PubSub,
18805            SubscriberConfig {
18806                preconfirmations: PreconfirmationMode::Required,
18807                ..SubscriberConfig::default()
18808            },
18809        )
18810        .with_provider_ref(ProviderRef::new("op-paid", 12));
18811        subscriber.chain_id = Some(10);
18812        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18813        subscriber.interests = subscriber.base_interests.clone();
18814
18815        let error = match subscriber
18816            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18817            .await
18818        {
18819            Err(error) => error,
18820            Ok(_) => panic!("a receipt for another transaction must fail closed"),
18821        };
18822        assert!(
18823            error
18824                .to_string()
18825                .contains("hash disagrees with its request")
18826        );
18827        assert!(asserter.read_q().is_empty());
18828    }
18829
18830    #[tokio::test]
18831    async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
18832        let asserter = Asserter::new();
18833        let transaction_a = B256::repeat_byte(0x41);
18834        let transaction_b = B256::repeat_byte(0x42);
18835        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18836            alloy_network::primitives::BlockTransactions::Hashes(vec![
18837                transaction_a,
18838                transaction_b,
18839            ]),
18840        );
18841        let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
18842            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
18843        );
18844        queue_op_pending(&asserter, first);
18845        asserter.push_success(&Vec::<Log>::new());
18846        asserter.push_success(&serde_json::Value::Null);
18847        asserter.push_success(&serde_json::Value::Null);
18848        queue_op_pending(&asserter, regressive.clone());
18849        queue_op_pending(&asserter, regressive);
18850        asserter.push_success(&Vec::<Log>::new());
18851        asserter.push_success(&serde_json::Value::Null);
18852        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18853        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18854            provider,
18855            SubscriberMode::PubSub,
18856            SubscriberConfig {
18857                preconfirmations: PreconfirmationMode::Required,
18858                ..SubscriberConfig::default()
18859            },
18860        )
18861        .with_provider_ref(ProviderRef::new("op-paid", 12));
18862        subscriber.chain_id = Some(10);
18863        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18864        subscriber.interests = subscriber.base_interests.clone();
18865
18866        assert!(
18867            subscriber
18868                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18869                .await
18870                .expect("first pending view is coherent")
18871                .is_some()
18872        );
18873        assert!(matches!(
18874            subscriber
18875                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18876                .await
18877                .expect("regression revokes instead of terminating the stream"),
18878            Some(SubscriberEvent::FlashblockInvalidated)
18879        ));
18880        assert!(subscriber.latest_preconfirmation.is_none());
18881        assert!(subscriber.pending_preconfirmation_invalidation);
18882        assert!(
18883            subscriber
18884                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18885                .await
18886                .expect("a later coherent view establishes a fresh snapshot")
18887                .is_some()
18888        );
18889        assert!(subscriber.latest_preconfirmation.is_some());
18890        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
18891        assert!(asserter.read_q().is_empty());
18892    }
18893
18894    #[tokio::test]
18895    async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
18896        let asserter = Asserter::new();
18897        let first = rpc_block(101, B256::repeat_byte(0xa1));
18898        let second = rpc_block(102, B256::repeat_byte(0xa2));
18899        queue_op_pending(&asserter, first);
18900        asserter.push_success(&Vec::<Log>::new());
18901        queue_op_pending(&asserter, second);
18902        asserter.push_success(&Vec::<Log>::new());
18903        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18904        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18905            provider,
18906            SubscriberMode::PubSub,
18907            SubscriberConfig {
18908                preconfirmations: PreconfirmationMode::Required,
18909                ..SubscriberConfig::default()
18910            },
18911        )
18912        .with_provider_ref(ProviderRef::new("op-paid", 12));
18913        subscriber.chain_id = Some(10);
18914        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18915        subscriber.interests = subscriber.base_interests.clone();
18916
18917        subscriber
18918            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18919            .await
18920            .expect("first quiet payload is observed");
18921        assert!(!subscriber.pending_preconfirmation_invalidation);
18922        subscriber
18923            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18924            .await
18925            .expect("replacement quiet payload is observed");
18926        assert!(subscriber.pending_preconfirmation_invalidation);
18927        assert_eq!(
18928            subscriber
18929                .latest_preconfirmation
18930                .as_ref()
18931                .map(|flashblock| flashblock.block_number),
18932            Some(102)
18933        );
18934        assert!(asserter.read_q().is_empty());
18935    }
18936
18937    #[tokio::test]
18938    async fn optimism_sampler_rejects_malformed_pending_receipts() {
18939        let asserter = Asserter::new();
18940        let transaction = B256::repeat_byte(0x42);
18941        let pending = rpc_block(101, B256::ZERO).with_transactions(
18942            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
18943        );
18944        queue_op_pending(&asserter, pending);
18945        asserter.push_success(&Vec::<Log>::new());
18946        asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
18947        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18948        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18949            provider,
18950            SubscriberMode::PubSub,
18951            SubscriberConfig {
18952                preconfirmations: PreconfirmationMode::Required,
18953                ..SubscriberConfig::default()
18954            },
18955        )
18956        .with_provider_ref(ProviderRef::new("op-paid", 12));
18957        subscriber.chain_id = Some(10);
18958        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18959        subscriber.interests = subscriber.base_interests.clone();
18960
18961        let error = match subscriber
18962            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
18963            .await
18964        {
18965            Err(error) => error,
18966            Ok(_) => panic!("malformed receipt content must fail closed"),
18967        };
18968        assert!(error.to_string().contains("missing its log array"));
18969        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
18970        assert!(asserter.read_q().is_empty());
18971    }
18972
18973    #[tokio::test]
18974    async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
18975        let asserter = Asserter::new();
18976        let transaction_a = B256::repeat_byte(0x41);
18977        let transaction_b = B256::repeat_byte(0x42);
18978        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
18979            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
18980        );
18981        let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
18982            alloy_network::primitives::BlockTransactions::Hashes(vec![
18983                transaction_a,
18984                transaction_b,
18985            ]),
18986        );
18987        let mut log = rpc_log(false);
18988        log.block_number = Some(101);
18989        log.block_hash = Some(B256::repeat_byte(0xa2));
18990        log.transaction_hash = Some(transaction_b);
18991        log.transaction_index = Some(1);
18992        log.log_index = Some(0);
18993        queue_op_pending(&asserter, first);
18994        asserter.push_success(&vec![log.clone()]);
18995        asserter.push_success(&serde_json::Value::Null);
18996        queue_op_pending(&asserter, second);
18997        asserter.push_success(&vec![log.clone()]);
18998        asserter.push_success(&serde_json::Value::Null);
18999        asserter.push_success(&serde_json::json!({
19000            "transactionHash": transaction_b,
19001            "logs": [log]
19002        }));
19003        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19004        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19005            provider,
19006            SubscriberMode::PubSub,
19007            SubscriberConfig {
19008                preconfirmations: PreconfirmationMode::Required,
19009                ..SubscriberConfig::default()
19010            },
19011        )
19012        .with_provider_ref(ProviderRef::new("op-paid", 12));
19013        subscriber.chain_id = Some(10);
19014        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19015        subscriber.interests = subscriber.base_interests.clone();
19016
19017        assert!(
19018            subscriber
19019                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19020                .await
19021                .expect("a cross-request race remains retryable")
19022                .is_none()
19023        );
19024        assert!(
19025            subscriber
19026                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19027                .await
19028                .expect("the next coherent cumulative view is delivered")
19029                .is_some()
19030        );
19031        assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
19032        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
19033        assert!(asserter.read_q().is_empty());
19034    }
19035
19036    #[tokio::test]
19037    async fn optimism_sampler_uses_the_paired_pending_state_provider() {
19038        let stream_asserter = Asserter::new();
19039        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
19040        let state_asserter = Asserter::new();
19041        queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
19042        state_asserter.push_success(&Vec::<Log>::new());
19043        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
19044        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19045            stream_provider,
19046            SubscriberMode::PubSub,
19047            SubscriberConfig {
19048                preconfirmations: PreconfirmationMode::Required,
19049                ..SubscriberConfig::default()
19050            },
19051        )
19052        .with_provider_ref(ProviderRef::new("op-paid", 12))
19053        .with_flashblocks_state_provider(state_provider);
19054        subscriber.chain_id = Some(10);
19055        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19056        subscriber.interests = subscriber.base_interests.clone();
19057
19058        assert!(
19059            subscriber
19060                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19061                .await
19062                .expect("paired pending-state reads succeed")
19063                .is_some()
19064        );
19065        assert!(state_asserter.read_q().is_empty());
19066        assert!(stream_asserter.read_q().is_empty());
19067    }
19068
19069    #[tokio::test]
19070    async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
19071        let asserter = Asserter::new();
19072        let pending = rpc_block(101, B256::ZERO);
19073        queue_op_pending(&asserter, pending.clone());
19074        asserter.push_failure_msg("temporarily unavailable");
19075        queue_op_pending(&asserter, pending);
19076        asserter.push_success(&Vec::<Log>::new());
19077        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19078        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19079            provider,
19080            SubscriberMode::PubSub,
19081            SubscriberConfig {
19082                preconfirmations: PreconfirmationMode::Required,
19083                max_consecutive_flashblock_poll_failures: 2,
19084                ..SubscriberConfig::default()
19085            },
19086        )
19087        .with_provider_ref(ProviderRef::new("op-paid", 12));
19088        subscriber.chain_id = Some(10);
19089        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19090        subscriber.interests = subscriber.base_interests.clone();
19091
19092        assert!(
19093            subscriber
19094                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19095                .await
19096                .expect("one request failure stays retryable")
19097                .is_none()
19098        );
19099        assert!(
19100            subscriber
19101                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19102                .await
19103                .expect("the next cumulative view retries the missing logs")
19104                .is_some()
19105        );
19106        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
19107        assert!(asserter.read_q().is_empty());
19108    }
19109
19110    #[tokio::test]
19111    async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
19112        let asserter = Asserter::new();
19113        asserter.push_failure_msg("temporarily unavailable");
19114        asserter.push_failure_msg("still unavailable");
19115        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19116        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19117            provider,
19118            SubscriberMode::PubSub,
19119            SubscriberConfig {
19120                preconfirmations: PreconfirmationMode::Required,
19121                max_consecutive_flashblock_poll_failures: 2,
19122                ..SubscriberConfig::default()
19123            },
19124        )
19125        .with_provider_ref(ProviderRef::new("op-paid", 12));
19126        subscriber.chain_id = Some(10);
19127
19128        assert!(
19129            subscriber
19130                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19131                .await
19132                .expect("the first request failure stays retryable")
19133                .is_none()
19134        );
19135        let error = match subscriber
19136            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19137            .await
19138        {
19139            Err(error) => error,
19140            Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
19141        };
19142        assert!(error.to_string().contains("still unavailable"));
19143        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
19144        assert!(asserter.read_q().is_empty());
19145    }
19146
19147    #[tokio::test]
19148    async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
19149        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19150        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19151            provider,
19152            SubscriberMode::PubSub,
19153            SubscriberConfig {
19154                preconfirmations: PreconfirmationMode::Required,
19155                ..SubscriberConfig::default()
19156            },
19157        )
19158        .with_provider_ref(ProviderRef::new("wrong-chain", 1));
19159        subscriber.chain_id = Some(10);
19160        subscriber.interests = vec![log_interest_matching_rpc_log()];
19161
19162        assert!(matches!(
19163            subscriber.establish_flashblocks_preflight(8_453).await,
19164            Err(SubscriberError::ChainMismatch {
19165                expected: 8_453,
19166                actual: 10
19167            })
19168        ));
19169    }
19170
19171    #[tokio::test]
19172    async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
19173        let stream_asserter = Asserter::new();
19174        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
19175        let state_asserter = Asserter::new();
19176        state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
19177        state_asserter.push_success(&8_453_u64);
19178        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
19179        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19180            stream_provider,
19181            SubscriberMode::PubSub,
19182            SubscriberConfig {
19183                preconfirmations: PreconfirmationMode::Required,
19184                ..SubscriberConfig::default()
19185            },
19186        )
19187        .with_provider_ref(ProviderRef::new("op-paid", 12))
19188        .with_flashblocks_state_provider(state_provider);
19189        subscriber.chain_id = Some(10);
19190        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19191        subscriber.interests = subscriber.base_interests.clone();
19192        let desired = subscriber.pubsub_stream_sources();
19193        let mut streams = SubscriberStreams::new();
19194        for source in desired {
19195            streams.push(source, stream::pending().boxed());
19196        }
19197        subscriber.state = AlloySubscriberState::Active(streams);
19198        subscriber.sources_dirty = false;
19199        assert!(matches!(
19200            subscriber.establish_flashblocks_preflight(10).await,
19201            Err(SubscriberError::ChainMismatch {
19202                expected: 10,
19203                actual: 8_453
19204            })
19205        ));
19206        assert!(state_asserter.read_q().is_empty());
19207        assert!(stream_asserter.read_q().is_empty());
19208    }
19209
19210    #[test]
19211    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
19212        let parent = BlockRef {
19213            number: 79,
19214            hash: B256::repeat_byte(0x79),
19215            parent_hash: Some(B256::repeat_byte(0x78)),
19216            timestamp: Some(1_700_000_079),
19217        };
19218        let old_tip = BlockRef {
19219            number: 80,
19220            hash: B256::repeat_byte(0x80),
19221            parent_hash: Some(parent.hash),
19222            timestamp: Some(1_700_000_080),
19223        };
19224        let replacement = BlockRef {
19225            hash: B256::repeat_byte(0xe0),
19226            parent_hash: Some(B256::repeat_byte(0xdf)),
19227            ..old_tip
19228        };
19229        let mut state =
19230            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
19231
19232        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
19233            .expect("replacement metadata is structurally valid")
19234            .expect("unknown parent is an observable rewind");
19235
19236        assert_eq!(rewind.common_ancestor, None);
19237        assert_eq!(rewind.dropped, vec![parent, old_tip]);
19238        assert_eq!(state.retained_canonical_history(), &[replacement]);
19239        assert_eq!(state.coverage_head(), Some(&replacement));
19240        assert_eq!(state.safe_head(), None);
19241        assert_eq!(state.finalized_head(), None);
19242    }
19243
19244    #[test]
19245    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
19246        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
19247        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
19248        let encoded = serde_json::to_string(&valid).expect("serialize id");
19249        assert_eq!(
19250            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
19251            valid
19252        );
19253        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
19254    }
19255
19256    fn rpc_log(removed: bool) -> Log {
19257        Log {
19258            inner: alloy_primitives::Log::new_unchecked(
19259                Address::repeat_byte(0x42),
19260                vec![B256::repeat_byte(0x01)],
19261                Bytes::new(),
19262            ),
19263            block_hash: Some(B256::repeat_byte(0x02)),
19264            block_number: Some(7),
19265            block_timestamp: Some(1_700_000_000),
19266            transaction_hash: Some(B256::repeat_byte(0x03)),
19267            transaction_index: Some(4),
19268            log_index: Some(5),
19269            removed,
19270        }
19271    }
19272
19273    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
19274        use alloy_consensus::SignableTransaction as _;
19275
19276        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
19277            chain_id,
19278            ..Default::default()
19279        }
19280        .into_signed(alloy_primitives::Signature::test_signature())
19281        .into();
19282        alloy_rpc_types_eth::Transaction {
19283            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
19284            block_hash: None,
19285            block_number: None,
19286            transaction_index: None,
19287            effective_gas_price: None,
19288        }
19289    }
19290
19291    #[cfg(feature = "reactive-ws")]
19292    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
19293        Log {
19294            inner: alloy_primitives::Log::new_unchecked(
19295                Address::repeat_byte(0x42),
19296                vec![B256::repeat_byte(0x01)],
19297                Bytes::new(),
19298            ),
19299            block_hash: Some(B256::repeat_byte(block_number as u8)),
19300            block_number: Some(block_number),
19301            block_timestamp: Some(1_700_000_000 + block_number),
19302            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
19303            transaction_index: Some(transaction_index),
19304            log_index: Some(log_index),
19305            removed: false,
19306        }
19307    }
19308
19309    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
19310    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
19311        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
19312            hash,
19313            inner: alloy_consensus::Header {
19314                number,
19315                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
19316                timestamp: 1_700_000_000 + number,
19317                ..Default::default()
19318            },
19319            total_difficulty: None,
19320            size: None,
19321        })
19322    }
19323
19324    fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
19325        let parent = rpc_block(
19326            pending.header().number().saturating_sub(1),
19327            pending.header().parent_hash(),
19328        );
19329        asserter.push_success(&Some(pending));
19330        asserter.push_success(&Some(parent));
19331    }
19332
19333    #[tokio::test(flavor = "multi_thread")]
19334    #[cfg(feature = "reactive-ws")]
19335    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
19336        let stream_asserter = Asserter::new();
19337        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
19338        let verification_asserter = Asserter::new();
19339        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
19340        let verification_provider =
19341            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
19342        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19343            provider,
19344            SubscriberMode::PubSub,
19345            SubscriberConfig {
19346                verify_log_block_context: true,
19347                ..SubscriberConfig::default()
19348            },
19349        )
19350        .with_log_verification_provider(verification_provider);
19351        let log = rpc_log_at(7, 0, 0);
19352
19353        subscriber
19354            .verify_log_block_context(&log)
19355            .await
19356            .expect("verify live log block");
19357        subscriber
19358            .verify_log_block_context(&log)
19359            .await
19360            .expect("reuse verified block cache");
19361        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
19362
19363        assert_eq!(
19364            record.context.block.expect("verified block").parent_hash,
19365            Some(B256::repeat_byte(6))
19366        );
19367        assert!(
19368            verification_asserter.read_q().is_empty(),
19369            "one provider lookup should verify every log in the same block"
19370        );
19371        assert!(
19372            stream_asserter.read_q().is_empty(),
19373            "verification must not use the high-volume stream provider"
19374        );
19375    }
19376
19377    #[tokio::test(flavor = "multi_thread")]
19378    async fn stream_with_termination_yields_terminal_source_marker() {
19379        let mut stream = stream_with_termination::<Ethereum, _>(
19380            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
19381                0xaa,
19382            ))]),
19383            SubscriberStreamSource::PubSubPendingHashes,
19384        );
19385
19386        assert!(matches!(
19387            stream.next().await,
19388            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
19389        ));
19390        assert!(matches!(
19391            stream.next().await,
19392            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
19393        ));
19394        assert!(stream.next().await.is_none());
19395    }
19396
19397    #[test]
19398    fn reconnect_delay_doubles_until_capped() {
19399        assert_eq!(
19400            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
19401            Duration::from_millis(500)
19402        );
19403        assert_eq!(
19404            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
19405            Duration::from_secs(1)
19406        );
19407        assert_eq!(
19408            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
19409            Duration::ZERO
19410        );
19411    }
19412
19413    #[test]
19414    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
19415        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
19416        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
19417
19418        assert!(should_dedupe_record(&included));
19419        assert!(!should_dedupe_record(&removed));
19420    }
19421
19422    #[test]
19423    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
19424        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
19425                                     timestamp: Option<u64>| {
19426            record.context.block.as_mut().expect("block").timestamp = timestamp;
19427            match &mut record.context.chain_status {
19428                ChainStatus::Included { block, .. }
19429                | ChainStatus::Safe { block }
19430                | ChainStatus::Finalized { block }
19431                | ChainStatus::Reorged {
19432                    dropped_from: block,
19433                } => block.timestamp = timestamp,
19434                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
19435                    panic!("log record is canonical")
19436                }
19437            }
19438        };
19439
19440        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
19441        let payload_timestamp = match &payload_only.input {
19442            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
19443            _ => unreachable!(),
19444        };
19445        set_context_timestamp(&mut payload_only, None);
19446        let mut context_only = payload_only.clone();
19447        if let ReactiveInput::Log(log) = &mut context_only.input {
19448            log.block_timestamp = None;
19449        }
19450        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
19451        assert!(matches!(
19452            dedupe_records(vec![payload_only, context_only]),
19453            Err(ReactiveError::InvalidInputRecord { .. })
19454        ));
19455
19456        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
19457        if let ReactiveInput::Log(log) = &mut partial.input {
19458            log.block_timestamp = None;
19459        }
19460        set_context_timestamp(&mut partial, None);
19461        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
19462        let deduped =
19463            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
19464        assert_eq!(deduped.len(), 1);
19465        deduped[0]
19466            .validated_identity()
19467            .expect("merged record remains coherent");
19468        let resolved = resolve_record_block_payload_metadata(
19469            &deduped[0],
19470            *canonical_record_block(&deduped[0]).expect("canonical"),
19471        )
19472        .expect("effective block");
19473        assert_eq!(resolved.timestamp, Some(payload_timestamp));
19474    }
19475
19476    #[test]
19477    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
19478        use alloy_rpc_types_eth::{Block, Header};
19479
19480        let block_ref = BlockRef {
19481            number: 7,
19482            hash: B256::repeat_byte(0x77),
19483            parent_hash: Some(B256::repeat_byte(0x66)),
19484            timestamp: Some(1_700_000_007),
19485        };
19486        let block = Block::empty(Header {
19487            hash: block_ref.hash,
19488            inner: alloy_consensus::Header {
19489                number: block_ref.number,
19490                parent_hash: block_ref.parent_hash.expect("parent"),
19491                timestamp: block_ref.timestamp.expect("timestamp"),
19492                ..Default::default()
19493            },
19494            total_difficulty: None,
19495            size: None,
19496        });
19497        let record = ReactiveInputRecord::<Ethereum>::new(
19498            ReactiveInput::FullBlock(block),
19499            ReactiveContext {
19500                chain_id: Some(1),
19501                source: InputSource::Subscription,
19502                chain_status: ChainStatus::Included {
19503                    block: block_ref,
19504                    confirmations: 0,
19505                },
19506                block: Some(block_ref),
19507                transaction_index: None,
19508                log_index: None,
19509            },
19510        );
19511
19512        assert!(!record.is_payload_deduplicable());
19513        assert!(!record.same_deduplicable_payload(&record));
19514        let retained = dedupe_scoped_records(vec![
19515            (
19516                record.clone(),
19517                DeliveryAudience::All,
19518                DeliveryScope::Canonical,
19519            ),
19520            (record, DeliveryAudience::All, DeliveryScope::Canonical),
19521        ])
19522        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
19523        assert_eq!(retained.len(), 2);
19524    }
19525
19526    #[test]
19527    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
19528        let pending_context = ReactiveContext {
19529            chain_id: Some(1),
19530            source: InputSource::Batch,
19531            chain_status: ChainStatus::Pending,
19532            block: None,
19533            transaction_index: None,
19534            log_index: None,
19535        };
19536        let mut included_pending = rpc_transaction(Some(1));
19537        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
19538        assert!(matches!(
19539            ReactiveInputRecord::<Ethereum>::new(
19540                ReactiveInput::PendingTx(included_pending),
19541                pending_context.clone(),
19542            )
19543            .validated_identity(),
19544            Err(ReactiveError::InvalidInputRecord { .. })
19545        ));
19546        assert!(matches!(
19547            ReactiveInputRecord::<Ethereum>::new(
19548                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
19549                pending_context,
19550            )
19551            .validated_identity(),
19552            Err(ReactiveError::InvalidInputRecord { .. })
19553        ));
19554
19555        let block_ref = BlockRef {
19556            number: 8,
19557            hash: B256::repeat_byte(0x88),
19558            parent_hash: Some(B256::repeat_byte(0x77)),
19559            timestamp: Some(1_700_000_008),
19560        };
19561        let header = alloy_rpc_types_eth::Header {
19562            hash: block_ref.hash,
19563            inner: alloy_consensus::Header {
19564                number: block_ref.number,
19565                parent_hash: block_ref.parent_hash.expect("parent"),
19566                timestamp: block_ref.timestamp.expect("timestamp"),
19567                ..Default::default()
19568            },
19569            total_difficulty: None,
19570            size: None,
19571        };
19572        let context = ReactiveContext {
19573            chain_id: Some(1),
19574            source: InputSource::Batch,
19575            chain_status: ChainStatus::Included {
19576                block: block_ref,
19577                confirmations: 0,
19578            },
19579            block: Some(block_ref),
19580            transaction_index: None,
19581            log_index: None,
19582        };
19583        for transaction in [
19584            alloy_rpc_types_eth::Transaction {
19585                block_hash: Some(B256::repeat_byte(0xff)),
19586                ..rpc_transaction(Some(1))
19587            },
19588            alloy_rpc_types_eth::Transaction {
19589                block_hash: Some(block_ref.hash),
19590                block_number: Some(block_ref.number),
19591                transaction_index: Some(1),
19592                ..rpc_transaction(Some(1))
19593            },
19594            rpc_transaction(Some(2)),
19595        ] {
19596            let block = alloy_rpc_types_eth::Block::new(
19597                header.clone(),
19598                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
19599            );
19600            assert!(matches!(
19601                ReactiveInputRecord::<Ethereum>::new(
19602                    ReactiveInput::FullBlock(block),
19603                    context.clone(),
19604                )
19605                .validated_identity(),
19606                Err(ReactiveError::InvalidInputRecord { .. })
19607            ));
19608        }
19609    }
19610
19611    #[test]
19612    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
19613        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19614        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19615            provider,
19616            SubscriberMode::Auto,
19617            SubscriberConfig::default(),
19618        );
19619        let owner = HandlerId::new("compat-owner");
19620        subscriber
19621            .add_interest_owner(
19622                owner.clone(),
19623                &[ReactiveInterest::Logs(LogInterest {
19624                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19625                    local_matcher: None,
19626                    route_key: None,
19627                })],
19628            )
19629            .unwrap();
19630        let log = rpc_log(false);
19631
19632        subscriber.enqueue_compat_owner_record(
19633            log_input_record(log.clone(), InputSource::Backfill),
19634            owner.clone(),
19635        );
19636        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
19637
19638        let batch = subscriber
19639            .drain_next_scoped_batch()
19640            .expect("owner catch-up and residual live copies");
19641        assert_eq!(batch.records.len(), 2);
19642        assert_eq!(
19643            batch.records[0].scope,
19644            SubscriberInputScope::OwnerOnlyHandlers {
19645                owners: vec![owner.clone()]
19646            }
19647        );
19648        assert_eq!(
19649            batch.records[1].scope,
19650            SubscriberInputScope::CanonicalResidual {
19651                owners: Vec::new(),
19652                excluded: vec![owner.clone()]
19653            }
19654        );
19655
19656        let reactive = batch.into_reactive_batch();
19657        assert_eq!(
19658            reactive.record_audience(0),
19659            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
19660        );
19661        assert_eq!(
19662            reactive.record_delivery_scope(0),
19663            Some(DeliveryScope::OwnerCatchup)
19664        );
19665        assert_eq!(
19666            reactive.record_audience(1),
19667            Some(&DeliveryAudience::AllExcept(vec![owner]))
19668        );
19669        assert_eq!(
19670            reactive.record_delivery_scope(1),
19671            Some(DeliveryScope::Canonical)
19672        );
19673    }
19674
19675    #[test]
19676    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
19677        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19678        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19679            provider,
19680            SubscriberMode::Auto,
19681            SubscriberConfig::default(),
19682        );
19683        let owner = HandlerId::new("replace-owner");
19684        let original = ReactiveInterest::Logs(LogInterest {
19685            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
19686            local_matcher: None,
19687            route_key: None,
19688        });
19689        let replacement_interest = ReactiveInterest::Logs(LogInterest {
19690            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19691            local_matcher: None,
19692            route_key: None,
19693        });
19694        let active = subscriber
19695            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
19696            .unwrap();
19697        assert!(subscriber.activate_interest_owner(&active));
19698        let replacement = subscriber
19699            .stage_interest_owner_replacement(
19700                owner,
19701                &[replacement_interest],
19702                SubscriberOwnerStart::Live,
19703            )
19704            .unwrap();
19705
19706        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
19707        assert_eq!(subscriber.interest_owner_state(&active), None);
19708        assert_eq!(
19709            subscriber.interest_owner_state(&replacement),
19710            Some(SubscriberOwnerState::Active)
19711        );
19712        assert_eq!(subscriber.registered_interests().len(), 1);
19713    }
19714
19715    #[test]
19716    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
19717        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19718        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19719            provider,
19720            SubscriberMode::Auto,
19721            SubscriberConfig::default(),
19722        );
19723        let owner = HandlerId::new("one-lifecycle");
19724        let interest = ReactiveInterest::Logs(LogInterest {
19725            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19726            local_matcher: None,
19727            route_key: None,
19728        });
19729        let epoch = subscriber
19730            .stage_interest_owner(
19731                owner.clone(),
19732                std::slice::from_ref(&interest),
19733                SubscriberOwnerStart::Live,
19734            )
19735            .expect("stage epoch owner");
19736
19737        assert!(matches!(
19738            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
19739            Err(SubscriberError::InvalidConfig(_))
19740        ));
19741        assert_eq!(
19742            subscriber.interest_owner_state(&epoch),
19743            Some(SubscriberOwnerState::Staged)
19744        );
19745        assert!(subscriber.abort_interest_owner(&epoch));
19746        subscriber
19747            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
19748            .expect("compatibility owner after epoch abort");
19749        assert!(matches!(
19750            subscriber.stage_interest_owner_replacement(
19751                owner,
19752                std::slice::from_ref(&interest),
19753                SubscriberOwnerStart::Live,
19754            ),
19755            Err(SubscriberOwnerError::AlreadyRegistered(_))
19756        ));
19757    }
19758
19759    #[test]
19760    fn pending_record_overflow_is_sticky_and_fail_closed() {
19761        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19762        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19763            provider,
19764            SubscriberMode::Polling,
19765            SubscriberConfig {
19766                max_pending_records: 1,
19767                ..SubscriberConfig::default()
19768            },
19769        );
19770        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
19771            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19772            local_matcher: None,
19773            route_key: None,
19774        })];
19775        subscriber.enqueue_event(SubscriberEvent::Log {
19776            source_id: 0,
19777            log: rpc_log(false),
19778        });
19779        let mut second = rpc_log(false);
19780        second.log_index = Some(6);
19781        second.transaction_hash = Some(B256::repeat_byte(0x04));
19782        subscriber.enqueue_event(SubscriberEvent::Log {
19783            source_id: 0,
19784            log: second,
19785        });
19786
19787        assert_eq!(subscriber.pending_records.len(), 1);
19788        assert!(matches!(
19789            subscriber.check_resource_error(),
19790            Err(SubscriberError::ResourceExhausted(_))
19791        ));
19792        subscriber.reset_delivery_state();
19793        assert!(subscriber.check_resource_error().is_ok());
19794    }
19795
19796    #[test]
19797    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
19798        let baseline = rpc_log(false);
19799        let fixed_bytes =
19800            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
19801                .expect("measure fixed log accounting");
19802        let mut large = baseline;
19803        large.inner = alloy_primitives::Log::new_unchecked(
19804            Address::repeat_byte(0x42),
19805            vec![B256::repeat_byte(0x01)],
19806            Bytes::from(vec![0u8; 256]),
19807        );
19808
19809        assert!(matches!(
19810            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
19811            Err(SubscriberError::ResourceExhausted(_))
19812        ));
19813    }
19814
19815    #[tokio::test(flavor = "multi_thread")]
19816    #[cfg(feature = "reactive-polling")]
19817    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
19818        use alloy_rpc_types_eth::{Block, Header};
19819
19820        let asserter = Asserter::new();
19821        let baseline = BlockRef {
19822            number: 6,
19823            hash: B256::repeat_byte(6),
19824            parent_hash: Some(B256::repeat_byte(5)),
19825            timestamp: Some(1_700_000_006),
19826        };
19827        let through = BlockRef {
19828            number: 7,
19829            hash: B256::repeat_byte(7),
19830            parent_hash: Some(baseline.hash),
19831            timestamp: Some(1_700_000_007),
19832        };
19833        let rpc_block = || -> Block {
19834            Block::empty(Header {
19835                hash: through.hash,
19836                inner: alloy_consensus::Header {
19837                    number: through.number,
19838                    parent_hash: through.parent_hash.expect("parent"),
19839                    timestamp: through.timestamp.expect("timestamp"),
19840                    ..Default::default()
19841                },
19842                total_difficulty: None,
19843                size: None,
19844            })
19845        };
19846        let mut historical = rpc_log(false);
19847        historical.block_hash = Some(through.hash);
19848        historical.block_timestamp = through.timestamp;
19849        asserter.push_success(&Some(rpc_block()));
19850        asserter.push_success(&vec![historical]);
19851        asserter.push_success(&Some(rpc_block()));
19852        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19853        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19854            provider,
19855            SubscriberMode::Polling,
19856            SubscriberConfig {
19857                max_pending_records: 1,
19858                ..SubscriberConfig::default()
19859            },
19860        );
19861        subscriber.chain_id = Some(1);
19862        let interest = ReactiveInterest::Logs(LogInterest {
19863            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19864            local_matcher: None,
19865            route_key: None,
19866        });
19867        let epoch = subscriber
19868            .stage_interest_owner(
19869                HandlerId::new("capacity-owner"),
19870                std::slice::from_ref(&interest),
19871                SubscriberOwnerStart::PostBlock(baseline),
19872            )
19873            .expect("stage owner");
19874        // Isolate the commit-side capacity edge: the live queue acquired one
19875        // canonical record while the historical request was in flight.
19876        subscriber.sources_dirty = false;
19877        subscriber.state = AlloySubscriberState::Empty;
19878        subscriber.push_pending_record(SubscriberInputRecord {
19879            record: log_input_record(rpc_log(false), InputSource::Poll),
19880            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
19881        });
19882
19883        let error = subscriber
19884            .reconcile_interest_owner(&epoch, through)
19885            .await
19886            .expect_err("historical delivery cannot displace the queued live record");
19887        assert!(matches!(
19888            error,
19889            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
19890        ));
19891        assert!(subscriber.interest_owner_progress(&epoch).is_none());
19892        assert_eq!(subscriber.pending_records.len(), 1);
19893        assert!(matches!(
19894            subscriber.pending_records[0].scope,
19895            SubscriberInputScope::Canonical { .. }
19896        ));
19897    }
19898
19899    #[test]
19900    fn lazy_backfill_queue_capacity_failure_is_atomic() {
19901        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19902        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19903            provider,
19904            SubscriberMode::Auto,
19905            SubscriberConfig {
19906                max_pending_backfills: 1,
19907                ..SubscriberConfig::default()
19908            },
19909        );
19910        let interest = |address| {
19911            ReactiveInterest::Logs(LogInterest {
19912                provider_filter: Filter::new().address(address),
19913                local_matcher: None,
19914                route_key: None,
19915            })
19916        };
19917        subscriber
19918            .add_interest_owner_with_backfill(
19919                HandlerId::new("owner-a"),
19920                &[interest(Address::repeat_byte(0x41))],
19921                SubscriberBackfill::from_block(10),
19922            )
19923            .expect("first queued backfill");
19924
19925        let error = subscriber
19926            .add_interest_owner_with_backfill(
19927                HandlerId::new("owner-b"),
19928                &[interest(Address::repeat_byte(0x42))],
19929                SubscriberBackfill::from_block(10),
19930            )
19931            .expect_err("second backfill must exceed capacity");
19932
19933        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
19934        assert!(
19935            subscriber
19936                .owner_interests(&HandlerId::new("owner-b"))
19937                .is_none()
19938        );
19939        assert_eq!(subscriber.pending_backfills.len(), 1);
19940    }
19941
19942    #[test]
19943    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
19944        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19945        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19946            provider,
19947            SubscriberMode::Auto,
19948            SubscriberConfig {
19949                max_pending_backfills: 1,
19950                ..SubscriberConfig::default()
19951            },
19952        );
19953        let interest = |address| {
19954            ReactiveInterest::Logs(LogInterest {
19955                provider_filter: Filter::new().address(address),
19956                local_matcher: None,
19957                route_key: None,
19958            })
19959        };
19960        subscriber
19961            .add_interest_owner(
19962                HandlerId::new("crash-stale"),
19963                &[interest(Address::repeat_byte(0xee))],
19964            )
19965            .expect("seed stale owner");
19966        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
19967        subscriber.rebuild_registered_interests();
19968        subscriber.push_pending_record(SubscriberInputRecord {
19969            record: log_input_record(rpc_log(false), InputSource::Poll),
19970            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
19971        });
19972        let baseline = BlockRef {
19973            number: 100,
19974            hash: B256::repeat_byte(100),
19975            parent_hash: Some(B256::repeat_byte(99)),
19976            timestamp: Some(1_700_000_100),
19977        };
19978        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
19979
19980        let error = subscriber
19981            .replace_interest_owners_with_global_backfill(
19982                vec![
19983                    (
19984                        HandlerId::new("pool-a"),
19985                        vec![interest(Address::repeat_byte(0xa1))],
19986                    ),
19987                    (
19988                        HandlerId::new("pool-b"),
19989                        vec![ReactiveInterest::Logs(LogInterest {
19990                            // A distinct block option prevents provider-filter
19991                            // fan-in, exercising the two-unit capacity edge.
19992                            provider_filter: Filter::new()
19993                                .address(Address::repeat_byte(0xb2))
19994                                .from_block(7),
19995                            local_matcher: None,
19996                            route_key: None,
19997                        })],
19998                    ),
19999                ],
20000                backfill,
20001            )
20002            .expect_err("two backfills exceed atomic capacity");
20003        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
20004        assert!(
20005            subscriber
20006                .owner_interests(&HandlerId::new("crash-stale"))
20007                .is_some(),
20008            "failed replacement must preserve the prior topology"
20009        );
20010        assert!(
20011            subscriber
20012                .owner_interests(&HandlerId::new("pool-a"))
20013                .is_none()
20014        );
20015        assert_eq!(subscriber.base_interests.len(), 1);
20016        assert_eq!(subscriber.pending_records.len(), 1);
20017
20018        subscriber
20019            .replace_interest_owners_with_global_backfill(
20020                vec![(
20021                    HandlerId::new("pool-a"),
20022                    vec![interest(Address::repeat_byte(0xa1))],
20023                )],
20024                backfill,
20025            )
20026            .expect("replacement within capacity");
20027        assert!(
20028            subscriber
20029                .owner_interests(&HandlerId::new("crash-stale"))
20030                .is_none(),
20031            "successful exact replacement removes stale owners"
20032        );
20033        assert!(
20034            subscriber.base_interests.is_empty(),
20035            "successful exact replacement removes stale unowned interests"
20036        );
20037        assert!(
20038            subscriber.drain_next_scoped_batch().is_none(),
20039            "stale canonical delivery must not escape before C + 1 recovery"
20040        );
20041        assert!(
20042            subscriber
20043                .owner_interests(&HandlerId::new("pool-a"))
20044                .is_some()
20045        );
20046        assert_eq!(subscriber.pending_backfills.len(), 1);
20047        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
20048        assert!(
20049            subscriber.pending_backfills[0].owner.is_none(),
20050            "startup history must be global canonical catch-up, not owner-only"
20051        );
20052    }
20053
20054    #[test]
20055    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
20056        let baseline = BlockRef {
20057            number: u64::MAX,
20058            hash: B256::repeat_byte(0xff),
20059            parent_hash: None,
20060            timestamp: None,
20061        };
20062        assert!(matches!(
20063            SubscriberBackfill::after_canonical_block(baseline),
20064            Err(SubscriberError::InvalidConfig(_))
20065        ));
20066    }
20067
20068    #[tokio::test(flavor = "multi_thread")]
20069    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
20070        let asserter = Asserter::new();
20071        asserter.push_success(&101u64);
20072        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
20073        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
20074        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20075        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20076            provider,
20077            SubscriberMode::Auto,
20078            SubscriberConfig::default(),
20079        );
20080        let baseline = BlockRef {
20081            number: 100,
20082            hash: B256::repeat_byte(0xaa),
20083            parent_hash: None,
20084            timestamp: None,
20085        };
20086        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
20087        subscriber
20088            .add_interest_owner_with_backfill(
20089                HandlerId::new("pool"),
20090                &[ReactiveInterest::Logs(LogInterest {
20091                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
20092                    local_matcher: None,
20093                    route_key: None,
20094                })],
20095                backfill,
20096            )
20097            .expect("queue post-baseline backfill");
20098
20099        let error = subscriber
20100            .drain_pending_backfills()
20101            .await
20102            .expect_err("provider branch differs at retained baseline");
20103        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
20104        assert_eq!(subscriber.pending_backfills.len(), 1);
20105        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
20106        assert!(subscriber.pending_records.is_empty());
20107    }
20108
20109    #[tokio::test(flavor = "multi_thread")]
20110    #[cfg(feature = "reactive-ws")]
20111    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
20112    {
20113        let asserter = Asserter::new();
20114        let retained = BlockRef {
20115            number: 10,
20116            hash: B256::repeat_byte(10),
20117            parent_hash: Some(B256::repeat_byte(9)),
20118            timestamp: Some(1_700_000_010),
20119        };
20120        let activation = BlockRef {
20121            number: 12,
20122            hash: B256::repeat_byte(12),
20123            parent_hash: Some(B256::repeat_byte(11)),
20124            timestamp: Some(1_700_000_012),
20125        };
20126
20127        // 257 distinct logical block options cross the 256-filter request
20128        // chunk boundary. Each window therefore makes two concurrent log
20129        // requests whose responses deliberately arrive in reverse order.
20130        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
20131        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
20132        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
20133        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
20134        asserter.push_success(&activation.number);
20135        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
20136        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
20137        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
20138        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
20139        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
20140        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
20141        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20142            provider,
20143            SubscriberMode::Auto,
20144            SubscriberConfig::default(),
20145        );
20146        let interests = (0..257)
20147            .map(|start| {
20148                ReactiveInterest::Logs(LogInterest {
20149                    provider_filter: Filter::new()
20150                        .address(Address::repeat_byte(0x42))
20151                        .event_signature(B256::repeat_byte(0x01))
20152                        .from_block(start),
20153                    local_matcher: None,
20154                    route_key: None,
20155                })
20156            })
20157            .collect::<Vec<_>>();
20158        subscriber
20159            .add_interest_owner_with_canonical_catchup(
20160                HandlerId::new("many-filters"),
20161                &interests,
20162                retained,
20163            )
20164            .expect("queue coordinated windows");
20165        assert_eq!(subscriber.pending_backfills.len(), 2);
20166        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
20167        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
20168
20169        subscriber
20170            .drain_pending_backfills()
20171            .await
20172            .expect("owner filter group");
20173        let owner = subscriber
20174            .drain_next_scoped_batch()
20175            .expect("owner ordered batch");
20176        assert_eq!(owner.records.len(), 2);
20177        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
20178        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
20179        assert!(
20180            owner.records.iter().all(|record| matches!(
20181                record.scope,
20182                SubscriberInputScope::OwnerOnlyHandlers { .. }
20183            ))
20184        );
20185
20186        subscriber
20187            .drain_pending_backfills()
20188            .await
20189            .expect("global filter group");
20190        let global = subscriber
20191            .drain_next_scoped_batch()
20192            .expect("global ordered batch");
20193        assert_eq!(global.records.len(), 2);
20194        assert_eq!(
20195            global.records[0].record.context.block.map(|b| b.number),
20196            Some(11)
20197        );
20198        assert_eq!(
20199            global.records[1].record.context.block.map(|b| b.number),
20200            Some(12)
20201        );
20202        assert!(
20203            global
20204                .records
20205                .iter()
20206                .all(|record| record.scope.is_canonical())
20207        );
20208        assert!(matches!(
20209            global.chain_controls.as_slice(),
20210            [ChainControl::Barrier {
20211                block: Some(block),
20212                ..
20213            }] if block == &activation
20214        ));
20215    }
20216
20217    #[tokio::test(flavor = "multi_thread")]
20218    #[cfg(feature = "reactive-ws")]
20219    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
20220        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20221        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20222            provider,
20223            SubscriberMode::PubSub,
20224            SubscriberConfig::default(),
20225        );
20226        subscriber.chain_id = Some(1);
20227        let interest = ReactiveInterest::Logs(LogInterest {
20228            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20229            local_matcher: None,
20230            route_key: None,
20231        });
20232        let owner_a = subscriber
20233            .stage_interest_owner(
20234                HandlerId::new("owner-a"),
20235                std::slice::from_ref(&interest),
20236                SubscriberOwnerStart::Live,
20237            )
20238            .unwrap();
20239        let owner_b = subscriber
20240            .stage_interest_owner(
20241                HandlerId::new("owner-b"),
20242                &[interest],
20243                SubscriberOwnerStart::Live,
20244            )
20245            .unwrap();
20246
20247        subscriber.enqueue_event(SubscriberEvent::Log {
20248            source_id: 0,
20249            log: rpc_log(false),
20250        });
20251        assert!(subscriber.abort_interest_owner(&owner_a));
20252
20253        let batch = subscriber
20254            .next_scoped_batch()
20255            .await
20256            .unwrap()
20257            .expect("shared canonical delivery remains queued");
20258        assert_eq!(batch.records.len(), 1);
20259        assert_eq!(
20260            batch.records[0].scope,
20261            SubscriberInputScope::Canonical {
20262                owners: vec![owner_b]
20263            }
20264        );
20265    }
20266
20267    #[tokio::test(flavor = "multi_thread")]
20268    #[cfg(feature = "reactive-ws")]
20269    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
20270        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20271        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20272            provider,
20273            SubscriberMode::PubSub,
20274            SubscriberConfig::default(),
20275        );
20276        subscriber.chain_id = Some(1);
20277        let epoch = subscriber
20278            .stage_interest_owner(
20279                HandlerId::new("owner"),
20280                &[ReactiveInterest::Logs(LogInterest {
20281                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20282                    local_matcher: None,
20283                    route_key: None,
20284                })],
20285                SubscriberOwnerStart::Live,
20286            )
20287            .unwrap();
20288        let log = rpc_log(false);
20289
20290        subscriber.enqueue_owner_record(
20291            log_input_record(log.clone(), InputSource::Backfill),
20292            epoch.clone(),
20293        );
20294        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
20295
20296        let batch = subscriber
20297            .next_scoped_batch()
20298            .await
20299            .unwrap()
20300            .expect("owner backfill and canonical live delivery");
20301        assert_eq!(batch.records.len(), 2);
20302        assert_eq!(
20303            batch.records[0].scope,
20304            SubscriberInputScope::OwnerOnly {
20305                owners: vec![epoch]
20306            }
20307        );
20308        assert_eq!(
20309            batch.records[1].scope,
20310            SubscriberInputScope::Canonical { owners: Vec::new() },
20311            "owner replay dedupe must not suppress the global live record"
20312        );
20313    }
20314
20315    #[tokio::test(flavor = "multi_thread")]
20316    #[cfg(feature = "reactive-polling")]
20317    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
20318        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20319        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20320            provider,
20321            SubscriberMode::Polling,
20322            SubscriberConfig {
20323                max_batch_size: 2,
20324                ..SubscriberConfig::default()
20325            },
20326        );
20327        let interest = ReactiveInterest::Logs(LogInterest {
20328            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20329            local_matcher: None,
20330            route_key: None,
20331        });
20332        let epoch = subscriber
20333            .stage_interest_owner(
20334                HandlerId::new("owner"),
20335                std::slice::from_ref(&interest),
20336                SubscriberOwnerStart::Live,
20337            )
20338            .unwrap();
20339        subscriber.sources_dirty = false;
20340
20341        let mut duplicate = rpc_log(false);
20342        duplicate.transaction_hash = Some(B256::repeat_byte(1));
20343        duplicate.log_index = Some(0);
20344        let events = (0u8..10).map(|index| {
20345            let mut log = rpc_log(false);
20346            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
20347            log.log_index = Some(index as u64);
20348            SubscriberEvent::Log { source_id: 0, log }
20349        });
20350        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
20351        let mut streams = SubscriberStreams::new();
20352        streams.push(
20353            SubscriberStreamSource::PollingLog { filter },
20354            stream::iter(events).boxed(),
20355        );
20356        subscriber.state = AlloySubscriberState::Active(streams);
20357
20358        let mut polls = 0usize;
20359        let fetched_duplicate = duplicate.clone();
20360        let fetch = poll_fn(move |cx| {
20361            polls += 1;
20362            if polls > 10 {
20363                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
20364            } else {
20365                cx.waker().wake_by_ref();
20366                std::task::Poll::Pending
20367            }
20368        });
20369        let target_epochs = HashSet::from([epoch.clone()]);
20370        let fetched_duplicate = subscriber
20371            .drive_reconcile_fetch(fetch, &target_epochs)
20372            .await
20373            .unwrap();
20374        subscriber.enqueue_owner_record_for_owners_unmerged(
20375            log_input_record(fetched_duplicate, InputSource::Backfill),
20376            vec![epoch.clone()],
20377        );
20378        subscriber.promote_reconcile_owner_records(&target_epochs);
20379
20380        assert_eq!(subscriber.pending_records.len(), 20);
20381        assert!(subscriber.pending_records.iter().take(10).all(|record| {
20382            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
20383        }));
20384        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
20385            record.scope
20386                == SubscriberInputScope::OwnerOnly {
20387                    owners: vec![epoch.clone()],
20388                }
20389        }));
20390    }
20391
20392    #[tokio::test(flavor = "multi_thread")]
20393    #[cfg(feature = "reactive-polling")]
20394    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
20395        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20396        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20397            provider,
20398            SubscriberMode::Polling,
20399            SubscriberConfig::default(),
20400        );
20401        subscriber.chain_id = Some(1);
20402        subscriber.sources_dirty = false;
20403        let mut first_poll = true;
20404        let fetch = poll_fn(move |cx| {
20405            if first_poll {
20406                first_poll = false;
20407                cx.waker().wake_by_ref();
20408                std::task::Poll::Pending
20409            } else {
20410                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
20411            }
20412        });
20413
20414        let result = subscriber
20415            .drive_reconcile_fetch(fetch, &HashSet::new())
20416            .await
20417            .expect("an empty live topology must not be mistaken for termination");
20418        assert_eq!(result, "certified");
20419    }
20420
20421    #[tokio::test(flavor = "multi_thread")]
20422    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
20423    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
20424        use alloy_rpc_types_eth::{Block, Header};
20425
20426        let asserter = Asserter::new();
20427        let baseline = BlockRef {
20428            number: 100,
20429            hash: B256::repeat_byte(0x64),
20430            parent_hash: Some(B256::repeat_byte(0x63)),
20431            timestamp: Some(1_700_000_100),
20432        };
20433        let through = BlockRef {
20434            number: 101,
20435            hash: B256::repeat_byte(0x65),
20436            parent_hash: Some(baseline.hash),
20437            timestamp: Some(1_700_000_101),
20438        };
20439        let rpc_block = || -> Block {
20440            Block::empty(Header {
20441                hash: through.hash,
20442                inner: alloy_consensus::Header {
20443                    number: through.number,
20444                    parent_hash: through.parent_hash.unwrap(),
20445                    timestamp: through.timestamp.unwrap(),
20446                    ..Default::default()
20447                },
20448                total_difficulty: None,
20449                size: None,
20450            })
20451        };
20452        asserter.push_success(&Some(rpc_block()));
20453        asserter.push_success(&Vec::<Log>::new());
20454        asserter.push_success(&Some(rpc_block()));
20455        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20456        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20457            provider,
20458            SubscriberMode::PubSub,
20459            SubscriberConfig::default(),
20460        );
20461        subscriber.chain_id = Some(1);
20462        let interest = ReactiveInterest::Logs(LogInterest {
20463            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
20464            local_matcher: None,
20465            route_key: None,
20466        });
20467        let epoch = subscriber
20468            .stage_interest_owner(
20469                HandlerId::new("reconnect-anchor"),
20470                std::slice::from_ref(&interest),
20471                SubscriberOwnerStart::PostBlock(baseline),
20472            )
20473            .unwrap();
20474        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
20475        let source = SubscriberStreamSource::PubSubLog {
20476            id: subscriber.log_source_id(&filter),
20477            filter: filter.clone(),
20478        };
20479        let mut streams = SubscriberStreams::new();
20480        streams.push(source, stream::pending().boxed());
20481        subscriber.state = AlloySubscriberState::Active(streams);
20482        subscriber.sources_dirty = false;
20483
20484        subscriber
20485            .reconcile_interest_owner(&epoch, through)
20486            .await
20487            .unwrap();
20488        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
20489        assert!(asserter.read_q().is_empty());
20490    }
20491
20492    #[tokio::test(flavor = "multi_thread")]
20493    #[cfg(feature = "reactive-polling")]
20494    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
20495        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20496        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20497            provider,
20498            SubscriberMode::Polling,
20499            SubscriberConfig::default(),
20500        );
20501        let interest = ReactiveInterest::Logs(LogInterest {
20502            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20503            local_matcher: None,
20504            route_key: None,
20505        });
20506        let epoch = subscriber
20507            .stage_interest_owner(
20508                HandlerId::new("owner"),
20509                std::slice::from_ref(&interest),
20510                SubscriberOwnerStart::PostBlock(BlockRef {
20511                    number: 100,
20512                    hash: B256::repeat_byte(0x64),
20513                    parent_hash: None,
20514                    timestamp: None,
20515                }),
20516            )
20517            .unwrap();
20518        subscriber.sources_dirty = false;
20519
20520        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
20521        let event = SubscriberEvent::Log {
20522            source_id: 0,
20523            log: rpc_log(false),
20524        };
20525        let mut streams = SubscriberStreams::new();
20526        streams.push(
20527            SubscriberStreamSource::PollingLog { filter },
20528            stream::once(async move { event })
20529                .chain(stream::pending())
20530                .boxed(),
20531        );
20532        subscriber.state = AlloySubscriberState::Active(streams);
20533
20534        let targets = HashSet::from([epoch.clone()]);
20535        {
20536            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
20537            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
20538            futures::pin_mut!(drive);
20539            poll_fn(|cx| {
20540                assert!(drive.as_mut().poll(cx).is_pending());
20541                std::task::Poll::Ready(())
20542            })
20543            .await;
20544        }
20545
20546        assert_eq!(subscriber.pending_records.len(), 1);
20547        assert_eq!(
20548            subscriber.pending_records[0].scope,
20549            SubscriberInputScope::Canonical { owners: Vec::new() },
20550            "canonical delivery commits immediately at a cancellation-safe boundary"
20551        );
20552        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
20553
20554        subscriber
20555            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
20556            .await
20557            .unwrap();
20558        subscriber.promote_reconcile_owner_records(&targets);
20559        assert!(subscriber.pending_reconcile_owner_records.is_empty());
20560        assert_eq!(subscriber.pending_records.len(), 2);
20561        assert_eq!(
20562            subscriber.pending_records[0].scope,
20563            SubscriberInputScope::Canonical { owners: Vec::new() },
20564            "canonical delivery remains target-excluded"
20565        );
20566        assert_eq!(
20567            subscriber.pending_records[1].scope,
20568            SubscriberInputScope::OwnerOnly {
20569                owners: vec![epoch]
20570            },
20571            "retry commit appends hidden owner delivery after historical catch-up"
20572        );
20573    }
20574
20575    #[tokio::test(flavor = "multi_thread")]
20576    #[cfg(feature = "reactive-ws")]
20577    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
20578        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20579        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20580            provider,
20581            SubscriberMode::PubSub,
20582            SubscriberConfig {
20583                reconnect: SubscriberReconnectConfig {
20584                    initial_delay: Duration::from_secs(60),
20585                    ..SubscriberReconnectConfig::default()
20586                },
20587                ..SubscriberConfig::default()
20588            },
20589        );
20590        subscriber.chain_id = Some(1);
20591        let interest = ReactiveInterest::Logs(LogInterest {
20592            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20593            local_matcher: None,
20594            route_key: None,
20595        });
20596        let epoch = subscriber
20597            .stage_interest_owner(
20598                HandlerId::new("owner"),
20599                std::slice::from_ref(&interest),
20600                SubscriberOwnerStart::PostBlock(BlockRef {
20601                    number: 7,
20602                    hash: B256::repeat_byte(0x07),
20603                    parent_hash: Some(B256::repeat_byte(0x06)),
20604                    timestamp: Some(1_700_000_007),
20605                }),
20606            )
20607            .unwrap();
20608        subscriber.sources_dirty = false;
20609        subscriber.stream_revision = 1;
20610        let entry = subscriber
20611            .owned_interests
20612            .iter_mut()
20613            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
20614            .unwrap();
20615        entry.progress = Some(SubscriberOwnerProgress {
20616            owner: epoch.clone(),
20617            through: entry.baseline.unwrap(),
20618        });
20619        entry.progress_stream_revision = Some(1);
20620
20621        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
20622        let source = SubscriberStreamSource::PubSubLog {
20623            id: subscriber.log_source_id(&filter),
20624            filter,
20625        };
20626        let mut streams = SubscriberStreams::new();
20627        streams.push(
20628            source.clone(),
20629            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
20630        );
20631        subscriber.state = AlloySubscriberState::Active(streams);
20632        let prior_revision = subscriber.stream_revision;
20633
20634        let mut first_poll = true;
20635        let control = poll_fn(move |cx| {
20636            if first_poll {
20637                first_poll = false;
20638                cx.waker().wake_by_ref();
20639                std::task::Poll::Pending
20640            } else {
20641                std::task::Poll::Ready("stop")
20642            }
20643        });
20644        futures::pin_mut!(control);
20645        let outcome = subscriber
20646            .next_scoped_batch_or(control.as_mut())
20647            .await
20648            .unwrap();
20649
20650        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
20651        assert!(subscriber.sources_dirty);
20652        assert!(subscriber.stream_revision > prior_revision);
20653        assert!(
20654            !subscriber.activate_interest_owner(&epoch),
20655            "progress certified against the terminated stream revision is stale"
20656        );
20657    }
20658
20659    #[tokio::test]
20660    #[cfg(feature = "reactive-ws")]
20661    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
20662        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20663        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20664            provider,
20665            SubscriberMode::PubSub,
20666            SubscriberConfig::default(),
20667        );
20668        subscriber.chain_id = Some(1);
20669        subscriber
20670            .register_interests(&[
20671                ReactiveInterest::Logs(LogInterest {
20672                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
20673                    local_matcher: None,
20674                    route_key: None,
20675                }),
20676                ReactiveInterest::Logs(LogInterest {
20677                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
20678                    local_matcher: None,
20679                    route_key: None,
20680                }),
20681                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
20682            ])
20683            .await
20684            .expect("register base interests");
20685
20686        // The two default-block-option log filters merge into one address
20687        // superset (existing consolidation behavior), so there is one log source
20688        // — assigned id 0, before the pending-hash source.
20689        let sources = subscriber.stream_sources().expect("stream sources");
20690        assert_eq!(sources.len(), 2);
20691        assert!(matches!(
20692            &sources[0],
20693            SubscriberStreamSource::PubSubLog { id: 0, .. }
20694        ));
20695        assert!(matches!(
20696            sources[1],
20697            SubscriberStreamSource::PubSubPendingHashes
20698        ));
20699
20700        // Ids are stable across repeated source construction.
20701        let again = subscriber.stream_sources().expect("stream sources again");
20702        assert!(again[0].same_key(&sources[0]));
20703    }
20704
20705    #[tokio::test(flavor = "multi_thread")]
20706    #[cfg(feature = "reactive-ws")]
20707    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
20708        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20709        let mut subscriber = AlloySubscriber::new(
20710            provider,
20711            SubscriberMode::PubSub,
20712            SubscriberConfig {
20713                reconnect: SubscriberReconnectConfig {
20714                    initial_delay: Duration::ZERO,
20715                    retry_delay: Duration::ZERO,
20716                    max_delay: Duration::ZERO,
20717                    max_attempts: Some(1),
20718                    ..SubscriberReconnectConfig::default()
20719                },
20720                ..SubscriberConfig::default()
20721            },
20722        );
20723        subscriber.chain_id = Some(1);
20724        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
20725            PendingTxInterest::default(),
20726        )];
20727
20728        let mut streams = SubscriberStreams::new();
20729        let source = SubscriberStreamSource::PubSubPendingHashes;
20730        streams.push(
20731            source,
20732            stream::once(async {
20733                SubscriberEvent::<Ethereum>::StreamTerminated(
20734                    SubscriberStreamSource::PubSubPendingHashes,
20735                )
20736            })
20737            .boxed(),
20738        );
20739        subscriber.state = AlloySubscriberState::Active(streams);
20740
20741        let result = subscriber.next_batch().await;
20742        assert!(
20743            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
20744            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
20745        );
20746    }
20747
20748    #[tokio::test]
20749    #[cfg(feature = "reactive-ws")]
20750    async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
20751        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20752        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20753            provider,
20754            SubscriberMode::PubSub,
20755            SubscriberConfig {
20756                preconfirmations: PreconfirmationMode::Required,
20757                ..SubscriberConfig::default()
20758            },
20759        )
20760        .with_provider_ref(ProviderRef::new("base-paid", 7));
20761        subscriber.chain_id = Some(8_453);
20762        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20763        subscriber.interests = subscriber.base_interests.clone();
20764        subscriber.sources_dirty = false;
20765
20766        let preview: BaseFlashblockWirePayload = serde_json::from_str(
20767            r#"{
20768                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
20769                "number":"0x65",
20770                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
20771                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20772                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
20773                "timestamp":"0x6553f165",
20774                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
20775            }"#,
20776        )
20777        .unwrap();
20778        let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
20779        subscriber.latest_preconfirmation = Some(preview);
20780
20781        let mut streams = SubscriberStreams::new();
20782        streams.push(
20783            SubscriberStreamSource::BaseFlashblocks,
20784            stream::once(async {
20785                SubscriberEvent::<Ethereum>::StreamTerminated(
20786                    SubscriberStreamSource::BaseFlashblocks,
20787                )
20788            })
20789            .boxed(),
20790        );
20791        subscriber.state = AlloySubscriberState::Active(streams);
20792
20793        let batch = subscriber
20794            .next_scoped_batch()
20795            .await
20796            .expect("termination handling succeeds")
20797            .expect("invalidation is delivered");
20798        assert!(batch.preconfirmation_invalidated());
20799        assert!(subscriber.latest_preconfirmation.is_none());
20800        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
20801        assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
20802        assert!(
20803            subscriber
20804                .pending_flashblock_reconnect_sources
20805                .iter()
20806                .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
20807        );
20808        assert!(
20809            subscriber
20810                .pending_flashblock_reconnect_sources
20811                .iter()
20812                .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
20813        );
20814        let AlloySubscriberState::Active(streams) = &subscriber.state else {
20815            panic!("subscriber remains active while reconnect is pending")
20816        };
20817        assert!(
20818            streams
20819                .entries
20820                .iter()
20821                .all(|entry| !entry.source.is_flashblocks())
20822        );
20823    }
20824
20825    #[tokio::test]
20826    #[cfg(feature = "reactive-ws")]
20827    async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
20828        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20829        let filter = Filter::new().address(Address::repeat_byte(0x42));
20830        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20831            provider,
20832            SubscriberMode::PubSub,
20833            SubscriberConfig {
20834                preconfirmations: PreconfirmationMode::Preferred,
20835                reconnect: SubscriberReconnectConfig {
20836                    enabled: false,
20837                    ..SubscriberReconnectConfig::default()
20838                },
20839                ..SubscriberConfig::default()
20840            },
20841        )
20842        .with_provider_ref(ProviderRef::new("base-paid", 1));
20843        subscriber.chain_id = Some(8_453);
20844        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20845            provider_filter: filter.clone(),
20846            local_matcher: None,
20847            route_key: None,
20848        })];
20849        subscriber.interests = subscriber.base_interests.clone();
20850        subscriber.log_source_ids.insert(filter.clone(), 0);
20851        subscriber.next_log_source_id = 1;
20852
20853        let canonical_source = SubscriberStreamSource::PubSubLog {
20854            id: 0,
20855            filter: filter.clone(),
20856        };
20857        let mut streams = SubscriberStreams::new();
20858        streams.push(
20859            canonical_source.clone(),
20860            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
20861        );
20862        subscriber.state = AlloySubscriberState::Active(streams);
20863        subscriber.sources_dirty = true;
20864
20865        subscriber
20866            .ensure_streams()
20867            .await
20868            .expect("preferred Flashblocks setup degrades to canonical-only");
20869        let AlloySubscriberState::Active(streams) = &subscriber.state else {
20870            panic!("canonical stream remains active")
20871        };
20872        assert!(streams.contains_source(&canonical_source));
20873        assert!(
20874            streams
20875                .entries
20876                .iter()
20877                .all(|entry| !entry.source.is_flashblocks())
20878        );
20879        assert!(subscriber.pending_flashblock_reconnects.is_empty());
20880        assert!(!subscriber.sources_dirty);
20881    }
20882
20883    #[tokio::test]
20884    #[cfg(feature = "reactive-ws")]
20885    async fn required_initial_flashblock_rejection_remains_fail_closed() {
20886        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20887        let filter = Filter::new().address(Address::repeat_byte(0x42));
20888        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20889            provider,
20890            SubscriberMode::PubSub,
20891            SubscriberConfig {
20892                preconfirmations: PreconfirmationMode::Required,
20893                reconnect: SubscriberReconnectConfig {
20894                    enabled: false,
20895                    ..SubscriberReconnectConfig::default()
20896                },
20897                ..SubscriberConfig::default()
20898            },
20899        )
20900        .with_provider_ref(ProviderRef::new("base-paid", 1));
20901        subscriber.chain_id = Some(8_453);
20902        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20903            provider_filter: filter.clone(),
20904            local_matcher: None,
20905            route_key: None,
20906        })];
20907        subscriber.interests = subscriber.base_interests.clone();
20908        subscriber.log_source_ids.insert(filter.clone(), 0);
20909        subscriber.next_log_source_id = 1;
20910
20911        let canonical_source = SubscriberStreamSource::PubSubLog {
20912            id: 0,
20913            filter: filter.clone(),
20914        };
20915        let mut streams = SubscriberStreams::new();
20916        streams.push(
20917            canonical_source.clone(),
20918            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
20919        );
20920        subscriber.state = AlloySubscriberState::Active(streams);
20921        subscriber.sources_dirty = true;
20922
20923        let error = subscriber
20924            .ensure_streams()
20925            .await
20926            .expect_err("required Flashblocks setup must fail closed");
20927        assert!(matches!(error, SubscriberError::Provider(_)));
20928        let AlloySubscriberState::Active(streams) = &subscriber.state else {
20929            panic!("the already-connected canonical stream is retained")
20930        };
20931        assert!(streams.contains_source(&canonical_source));
20932    }
20933
20934    #[tokio::test]
20935    #[cfg(feature = "reactive-ws")]
20936    async fn preferred_flashblock_termination_preserves_canonical_delivery() {
20937        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20938        let filter = Filter::new().address(Address::repeat_byte(0x42));
20939        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20940            provider,
20941            SubscriberMode::PubSub,
20942            SubscriberConfig {
20943                preconfirmations: PreconfirmationMode::Preferred,
20944                reconnect: SubscriberReconnectConfig {
20945                    enabled: false,
20946                    ..SubscriberReconnectConfig::default()
20947                },
20948                ..SubscriberConfig::default()
20949            },
20950        )
20951        .with_provider_ref(ProviderRef::new("base-paid", 1));
20952        subscriber.chain_id = Some(8_453);
20953        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
20954            provider_filter: filter.clone(),
20955            local_matcher: None,
20956            route_key: None,
20957        })];
20958        subscriber.interests = subscriber.base_interests.clone();
20959        subscriber.log_source_ids.insert(filter.clone(), 0);
20960        subscriber.next_log_source_id = 1;
20961        subscriber.sources_dirty = false;
20962
20963        let mut streams = SubscriberStreams::new();
20964        streams.push(
20965            SubscriberStreamSource::BaseFlashblocks,
20966            stream::once(async {
20967                SubscriberEvent::<Ethereum>::StreamTerminated(
20968                    SubscriberStreamSource::BaseFlashblocks,
20969                )
20970            })
20971            .boxed(),
20972        );
20973        streams.push(
20974            SubscriberStreamSource::PubSubLog {
20975                id: 0,
20976                filter: filter.clone(),
20977            },
20978            stream::once(async {
20979                SubscriberEvent::<Ethereum>::Log {
20980                    source_id: 0,
20981                    log: rpc_log(false),
20982                }
20983            })
20984            .boxed(),
20985        );
20986        subscriber.state = AlloySubscriberState::Active(streams);
20987
20988        let invalidation = subscriber
20989            .next_scoped_batch()
20990            .await
20991            .expect("preferred termination does not fail")
20992            .expect("invalidation is delivered");
20993        assert!(invalidation.preconfirmation_invalidated());
20994
20995        let canonical = subscriber
20996            .next_scoped_batch()
20997            .await
20998            .expect("canonical stream remains healthy")
20999            .expect("canonical log is delivered");
21000        assert!(!canonical.preconfirmation_invalidated());
21001        assert_eq!(canonical.records().len(), 1);
21002        assert_eq!(
21003            canonical.records()[0].record.context.source,
21004            InputSource::Subscription
21005        );
21006    }
21007
21008    #[tokio::test]
21009    #[cfg(feature = "reactive-ws")]
21010    async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
21011        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21012        let filter = Filter::new().address(Address::repeat_byte(0x42));
21013        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21014            provider,
21015            SubscriberMode::PubSub,
21016            SubscriberConfig {
21017                preconfirmations: PreconfirmationMode::Preferred,
21018                reconnect: SubscriberReconnectConfig {
21019                    enabled: false,
21020                    ..SubscriberReconnectConfig::default()
21021                },
21022                ..SubscriberConfig::default()
21023            },
21024        )
21025        .with_provider_ref(ProviderRef::new("base-paid", 1));
21026        subscriber.chain_id = Some(8_453);
21027        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
21028            provider_filter: filter.clone(),
21029            local_matcher: None,
21030            route_key: None,
21031        })];
21032        subscriber.interests = subscriber.base_interests.clone();
21033        subscriber.log_source_ids.insert(filter.clone(), 0);
21034        subscriber.next_log_source_id = 1;
21035        subscriber.sources_dirty = false;
21036
21037        let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
21038        let mut streams = SubscriberStreams::new();
21039        streams.push(
21040            canonical_source,
21041            stream::once(async {
21042                tokio::time::sleep(Duration::from_millis(1)).await;
21043                SubscriberEvent::<Ethereum>::Log {
21044                    source_id: 0,
21045                    log: rpc_log(false),
21046                }
21047            })
21048            .boxed(),
21049        );
21050        subscriber.state = AlloySubscriberState::Active(streams);
21051
21052        let source = SubscriberStreamSource::BaseFlashblocks;
21053        subscriber
21054            .pending_flashblock_reconnect_sources
21055            .push(source.clone());
21056        subscriber
21057            .pending_flashblock_reconnects
21058            .push(Box::pin(async move {
21059                (
21060                    source,
21061                    Err(SubscriberError::Provider(
21062                        "test reconnect window exhausted".to_owned(),
21063                    )),
21064                )
21065            }));
21066
21067        let canonical = subscriber
21068            .next_scoped_batch()
21069            .await
21070            .expect("preferred reconnect exhaustion does not fail")
21071            .expect("canonical log is delivered");
21072        assert_eq!(canonical.records().len(), 1);
21073        assert!(subscriber.pending_flashblock_reconnects.is_empty());
21074    }
21075
21076    #[test]
21077    fn backfilled_logs_skip_recent_subscription_duplicates() {
21078        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21079        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21080            provider,
21081            SubscriberMode::PubSub,
21082            SubscriberConfig::default(),
21083        );
21084        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
21085            provider_filter: Filter::new()
21086                .address(Address::repeat_byte(0x42))
21087                .event_signature(B256::repeat_byte(0x01)),
21088            local_matcher: None,
21089            route_key: None,
21090        })];
21091
21092        let log = rpc_log(false);
21093        subscriber.enqueue_event(SubscriberEvent::Log {
21094            source_id: 0,
21095            log: log.clone(),
21096        });
21097        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
21098            source_id: 0,
21099            logs: vec![log],
21100        });
21101
21102        assert_eq!(subscriber.pending_records.len(), 1);
21103        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
21104        assert_eq!(
21105            subscriber.pending_records[0].context.source,
21106            InputSource::Subscription
21107        );
21108    }
21109
21110    #[test]
21111    fn backfilled_logs_surface_with_backfill_source() {
21112        // A backfilled log with no prior subscription duplicate is delivered as
21113        // an `InputSource::Backfill` record (the positive side of the dedup test,
21114        // pinning the README's "marking recovered records as InputSource::Backfill"
21115        // claim — the only place that source is produced).
21116        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21117        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21118            provider,
21119            SubscriberMode::PubSub,
21120            SubscriberConfig::default(),
21121        );
21122        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
21123            provider_filter: Filter::new()
21124                .address(Address::repeat_byte(0x42))
21125                .event_signature(B256::repeat_byte(0x01)),
21126            local_matcher: None,
21127            route_key: None,
21128        })];
21129
21130        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
21131            source_id: 0,
21132            logs: vec![rpc_log(false)],
21133        });
21134
21135        assert_eq!(subscriber.pending_records.len(), 1);
21136        assert_eq!(
21137            subscriber.pending_records[0].context.source,
21138            InputSource::Backfill
21139        );
21140        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
21141    }
21142
21143    #[test]
21144    #[cfg(feature = "reactive-ws")]
21145    fn owner_removal_preserves_delivery_and_dedupe_state() {
21146        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21147        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21148            provider,
21149            SubscriberMode::PubSub,
21150            SubscriberConfig::default(),
21151        );
21152        subscriber
21153            .add_interest_owner(
21154                HandlerId::new("pool-a"),
21155                &[ReactiveInterest::Logs(LogInterest {
21156                    provider_filter: Filter::new()
21157                        .address(Address::repeat_byte(0x42))
21158                        .event_signature(B256::repeat_byte(0x01)),
21159                    local_matcher: None,
21160                    route_key: None,
21161                })],
21162            )
21163            .expect("register pool-a owner");
21164        subscriber
21165            .add_interest_owner(
21166                HandlerId::new("pool-b"),
21167                &[ReactiveInterest::Logs(LogInterest {
21168                    provider_filter: Filter::new()
21169                        .address(Address::repeat_byte(0x24))
21170                        .event_signature(B256::repeat_byte(0x02)),
21171                    local_matcher: None,
21172                    route_key: None,
21173                })],
21174            )
21175            .expect("register pool-b owner");
21176
21177        // Allocate source ids the way live stream setup would (pool-a -> id 0),
21178        // so the injected delivery anchor hangs off a referenced filter.
21179        let sources = subscriber.stream_sources().expect("stream sources");
21180        subscriber.enqueue_event(SubscriberEvent::Log {
21181            source_id: 0,
21182            log: rpc_log(false),
21183        });
21184        let mut streams = SubscriberStreams::new();
21185        streams.push(
21186            sources[0].clone(),
21187            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
21188        );
21189        subscriber.state = AlloySubscriberState::Active(streams);
21190        assert_eq!(subscriber.pending_records.len(), 1);
21191        assert_eq!(subscriber.recent_input_refs.len(), 1);
21192        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
21193
21194        let removed = subscriber
21195            .remove_interest_owner(&HandlerId::new("pool-b"))
21196            .expect("pool-b should be removed");
21197
21198        assert_eq!(removed.len(), 1);
21199        assert_eq!(subscriber.pending_records.len(), 1);
21200        assert_eq!(subscriber.recent_input_refs.len(), 1);
21201        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
21202        assert!(
21203            subscriber
21204                .owner_interests(&HandlerId::new("pool-a"))
21205                .is_some()
21206        );
21207        assert!(
21208            subscriber
21209                .owner_interests(&HandlerId::new("pool-b"))
21210                .is_none()
21211        );
21212        assert_eq!(subscriber.registered_interests().len(), 1);
21213    }
21214
21215    #[test]
21216    #[cfg(feature = "reactive-ws")]
21217    fn owner_log_sources_fan_in_across_owners() {
21218        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21219        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21220            provider,
21221            SubscriberMode::PubSub,
21222            SubscriberConfig::default(),
21223        );
21224        subscriber
21225            .add_interest_owner(
21226                HandlerId::new("pool-a"),
21227                &[ReactiveInterest::Logs(LogInterest {
21228                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
21229                    local_matcher: None,
21230                    route_key: None,
21231                })],
21232            )
21233            .expect("register pool-a owner");
21234
21235        let initial_sources = subscriber.stream_sources().expect("initial sources");
21236        assert_eq!(initial_sources.len(), 1);
21237        let pool_a_source = initial_sources[0].clone();
21238        assert!(matches!(
21239            &pool_a_source,
21240            SubscriberStreamSource::PubSubLog { id: 0, .. }
21241        ));
21242
21243        subscriber
21244            .add_interest_owner(
21245                HandlerId::new("pool-b"),
21246                &[ReactiveInterest::Logs(LogInterest {
21247                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
21248                    local_matcher: None,
21249                    route_key: None,
21250                })],
21251            )
21252            .expect("register pool-b owner");
21253
21254        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
21255        assert_eq!(
21256            expanded_sources.len(),
21257            1,
21258            "compatible owner filters should share one provider subscription"
21259        );
21260        assert!(
21261            !expanded_sources[0].same_key(&pool_a_source),
21262            "the provider-facing superset changes while owner routing remains exact"
21263        );
21264
21265        subscriber
21266            .remove_interest_owner(&HandlerId::new("pool-b"))
21267            .expect("pool-b should be removed");
21268        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
21269        assert_eq!(trimmed_sources.len(), 1);
21270        assert!(trimmed_sources[0].same_key(&pool_a_source));
21271    }
21272
21273    #[test]
21274    #[cfg(feature = "reactive-ws")]
21275    fn provider_log_fan_in_respects_address_ceiling() {
21276        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21277        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21278            provider,
21279            SubscriberMode::PubSub,
21280            SubscriberConfig {
21281                max_log_addresses_per_subscription: 2,
21282                ..SubscriberConfig::default()
21283            },
21284        );
21285        for index in 0..5 {
21286            subscriber
21287                .add_interest_owner(
21288                    HandlerId::new(format!("pool-{index}")),
21289                    &[log_interest_for(index + 1)],
21290                )
21291                .expect("register pool owner");
21292        }
21293
21294        let sources = subscriber.stream_sources().expect("stream sources");
21295        assert_eq!(sources.len(), 3);
21296        let mut address_counts: Vec<_> = sources
21297            .iter()
21298            .map(|source| match source {
21299                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
21300                _ => panic!("expected log source"),
21301            })
21302            .collect();
21303        address_counts.sort_unstable();
21304        assert_eq!(address_counts, vec![1, 2, 2]);
21305    }
21306
21307    #[tokio::test(flavor = "multi_thread")]
21308    #[cfg(feature = "reactive-ws")]
21309    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
21310        let asserter = Asserter::new();
21311        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
21312        asserter.push_success(&vec![rpc_log(false)]);
21313        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
21314        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21315        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21316            provider,
21317            SubscriberMode::PubSub,
21318            SubscriberConfig::default(),
21319        );
21320        subscriber
21321            .add_interest_owner_with_backfill(
21322                HandlerId::new("pool-a"),
21323                &[ReactiveInterest::Logs(LogInterest {
21324                    provider_filter: Filter::new()
21325                        .address(Address::repeat_byte(0x42))
21326                        .event_signature(B256::repeat_byte(0x01)),
21327                    local_matcher: None,
21328                    route_key: None,
21329                })],
21330                SubscriberBackfill::range(1, 7),
21331            )
21332            .expect("register pool-a with backfill");
21333
21334        subscriber
21335            .drain_pending_backfills()
21336            .await
21337            .expect("owner backfill should drain");
21338
21339        assert_eq!(subscriber.pending_records.len(), 1);
21340        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
21341    }
21342
21343    #[tokio::test(flavor = "multi_thread")]
21344    async fn subscriber_streams_poll_ready_sources_round_robin() {
21345        let first_hash = B256::repeat_byte(0x01);
21346        let second_hash = B256::repeat_byte(0x02);
21347        let mut streams = SubscriberStreams::new();
21348        streams.push(
21349            SubscriberStreamSource::PubSubPendingHashes,
21350            stream::iter([
21351                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
21352                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
21353            ])
21354            .boxed(),
21355        );
21356        streams.push(
21357            SubscriberStreamSource::PubSubBlockHeaders,
21358            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
21359                .boxed(),
21360        );
21361
21362        assert!(matches!(
21363            streams.next().await,
21364            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
21365        ));
21366        assert!(matches!(
21367            streams.next().await,
21368            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
21369        ));
21370    }
21371
21372    #[tokio::test(flavor = "multi_thread")]
21373    #[cfg(feature = "reactive-ws")]
21374    async fn owner_updates_ensure_streams_without_full_reset() {
21375        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21376        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21377            provider,
21378            SubscriberMode::PubSub,
21379            SubscriberConfig::default(),
21380        );
21381        subscriber.chain_id = Some(1);
21382        subscriber
21383            .register_interests(&[ReactiveInterest::PendingTransactions(
21384                PendingTxInterest::default(),
21385            )])
21386            .await
21387            .expect("register base pending interest");
21388        subscriber
21389            .add_interest_owner(
21390                HandlerId::new("headers"),
21391                &[ReactiveInterest::Blocks(BlockInterest::default())],
21392            )
21393            .expect("register header owner");
21394
21395        let mut streams = SubscriberStreams::new();
21396        streams.push(
21397            SubscriberStreamSource::PubSubPendingHashes,
21398            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
21399        );
21400        streams.push(
21401            SubscriberStreamSource::PubSubBlockHeaders,
21402            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
21403        );
21404        subscriber.state = AlloySubscriberState::Active(streams);
21405
21406        subscriber
21407            .remove_interest_owner(&HandlerId::new("headers"))
21408            .expect("header owner should be removed");
21409        assert!(matches!(
21410            &subscriber.state,
21411            AlloySubscriberState::Active(streams) if streams.len() == 2
21412        ));
21413
21414        subscriber
21415            .ensure_streams()
21416            .await
21417            .expect("pure removal reconciliation should not touch provider");
21418
21419        assert!(matches!(
21420            &subscriber.state,
21421            AlloySubscriberState::Active(streams)
21422                if streams.len() == 1
21423                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
21424                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
21425        ));
21426
21427        subscriber
21428            .add_interest_owner(
21429                HandlerId::new("headers"),
21430                &[ReactiveInterest::Blocks(BlockInterest::default())],
21431            )
21432            .expect("re-add header owner");
21433        assert!(matches!(
21434            &subscriber.state,
21435            AlloySubscriberState::Active(streams) if streams.len() == 1
21436        ));
21437    }
21438
21439    #[tokio::test(flavor = "multi_thread")]
21440    #[cfg(feature = "reactive-polling")]
21441    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
21442        let asserter = Asserter::new();
21443        asserter.push_success(&U256::from(1));
21444        asserter.push_failure_msg("second filter connection failed");
21445        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21446        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21447            provider,
21448            SubscriberMode::Polling,
21449            SubscriberConfig {
21450                max_log_addresses_per_subscription: 1,
21451                ..SubscriberConfig::default()
21452            },
21453        );
21454        subscriber.chain_id = Some(1);
21455        subscriber
21456            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
21457            .await
21458            .expect("register two independently connected filters");
21459
21460        let error = subscriber
21461            .ensure_streams()
21462            .await
21463            .expect_err("second provider connection is forced to fail");
21464        assert!(matches!(error, SubscriberError::Provider(_)));
21465        assert!(subscriber.sources_dirty);
21466        let retained_streams = match &subscriber.state {
21467            AlloySubscriberState::Active(streams) => Some(streams.len()),
21468            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
21469        };
21470        assert_eq!(
21471            retained_streams,
21472            Some(1),
21473            "first connection must survive later error {error:?}; revision {}",
21474            subscriber.stream_revision
21475        );
21476
21477        asserter.push_success(&U256::from(2));
21478        subscriber
21479            .ensure_streams()
21480            .await
21481            .expect("retry connects only the missing source");
21482        assert!(!subscriber.sources_dirty);
21483        assert!(matches!(
21484            &subscriber.state,
21485            AlloySubscriberState::Active(streams) if streams.len() == 2
21486        ));
21487        assert!(asserter.read_q().is_empty());
21488    }
21489
21490    #[tokio::test(flavor = "multi_thread")]
21491    #[cfg(feature = "reactive-ws")]
21492    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
21493        let asserter = Asserter::new();
21494        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21495        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21496            provider,
21497            SubscriberMode::PubSub,
21498            SubscriberConfig::default(),
21499        );
21500        subscriber.chain_id = Some(1);
21501        subscriber
21502            .register_interests(&[log_interest_for(0x43)])
21503            .await
21504            .expect("register log source");
21505        let source = subscriber
21506            .stream_sources()
21507            .expect("one desired source")
21508            .pop()
21509            .expect("log source");
21510        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
21511            panic!("expected pubsub log source")
21512        };
21513        subscriber.last_seen_log_blocks.insert(id, 6);
21514
21515        {
21516            let source = SubscriberStreamSource::PubSubLog {
21517                id,
21518                filter: subscriber
21519                    .log_stream_filters()
21520                    .pop()
21521                    .expect("provider filter"),
21522            };
21523            let interrupted = async {
21524                subscriber.install_source_stream(
21525                    source.clone(),
21526                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
21527                );
21528                subscriber.queue_source_backfill(source);
21529                subscriber.sources_dirty = true;
21530                futures::future::pending::<()>().await;
21531            };
21532            futures::pin_mut!(interrupted);
21533            poll_fn(|cx| {
21534                assert!(interrupted.as_mut().poll(cx).is_pending());
21535                std::task::Poll::Ready(())
21536            })
21537            .await;
21538        }
21539
21540        assert_eq!(subscriber.pending_source_backfills.len(), 1);
21541        assert!(matches!(
21542            &subscriber.state,
21543            AlloySubscriberState::Active(streams) if streams.len() == 1
21544        ));
21545
21546        asserter.push_success(&7u64);
21547        asserter.push_success(&Vec::<Log>::new());
21548        subscriber
21549            .ensure_streams()
21550            .await
21551            .expect("retry completes only the pending historical window");
21552
21553        assert!(subscriber.pending_source_backfills.is_empty());
21554        assert!(!subscriber.sources_dirty);
21555        assert!(matches!(
21556            &subscriber.state,
21557            AlloySubscriberState::Active(streams) if streams.len() == 1
21558        ));
21559        assert!(asserter.read_q().is_empty());
21560    }
21561
21562    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
21563    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
21564    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
21565        ReactiveInterest::Logs(LogInterest {
21566            provider_filter: Filter::new()
21567                .address(Address::repeat_byte(0x42))
21568                .event_signature(B256::repeat_byte(0x01)),
21569            local_matcher: None,
21570            route_key: None,
21571        })
21572    }
21573
21574    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
21575    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
21576        ReactiveInterest::Logs(LogInterest {
21577            provider_filter: Filter::new().address(Address::repeat_byte(address)),
21578            local_matcher: None,
21579            route_key: None,
21580        })
21581    }
21582
21583    // B1: a transient provider error must not consume the queued backfill — the
21584    // missed window has to survive for the next poll to retry.
21585    #[tokio::test(flavor = "multi_thread")]
21586    #[cfg(feature = "reactive-ws")]
21587    async fn drain_backfill_retains_queue_entry_on_provider_error() {
21588        let asserter = Asserter::new();
21589        asserter.push_failure_msg("rate limited");
21590        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
21591        asserter.push_success(&vec![rpc_log(false)]);
21592        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
21593        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21594        let mut subscriber = AlloySubscriber::new(
21595            provider,
21596            SubscriberMode::PubSub,
21597            SubscriberConfig::default(),
21598        );
21599        subscriber
21600            .add_interest_owner_with_backfill(
21601                HandlerId::new("pool"),
21602                &[log_interest_matching_rpc_log()],
21603                SubscriberBackfill::range(1, 7),
21604            )
21605            .expect("register owner with backfill");
21606        assert_eq!(subscriber.pending_backfills.len(), 1);
21607
21608        let first = subscriber.drain_pending_backfills().await;
21609        assert!(first.is_err(), "provider failure should surface");
21610        assert_eq!(
21611            subscriber.pending_backfills.len(),
21612            1,
21613            "failed fetch must leave the backfill queued for retry"
21614        );
21615        assert!(subscriber.pending_records.is_empty());
21616
21617        subscriber
21618            .drain_pending_backfills()
21619            .await
21620            .expect("retry should succeed");
21621        assert!(subscriber.pending_backfills.is_empty());
21622        assert_eq!(subscriber.pending_records.len(), 1);
21623    }
21624
21625    // B3: a zero-log backfill window still advances the delivery anchor to its
21626    // upper bound, so a later reconnect catches up from the right block.
21627    #[tokio::test(flavor = "multi_thread")]
21628    #[cfg(feature = "reactive-ws")]
21629    async fn drain_backfill_seeds_anchor_on_empty_window() {
21630        let asserter = Asserter::new();
21631        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
21632        asserter.push_success(&Vec::<Log>::new());
21633        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
21634        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21635        let mut subscriber = AlloySubscriber::new(
21636            provider,
21637            SubscriberMode::PubSub,
21638            SubscriberConfig::default(),
21639        );
21640        subscriber
21641            .add_interest_owner_with_backfill(
21642                HandlerId::new("pool"),
21643                &[log_interest_matching_rpc_log()],
21644                SubscriberBackfill::range(1, 42),
21645            )
21646            .expect("register owner with backfill");
21647
21648        subscriber
21649            .drain_pending_backfills()
21650            .await
21651            .expect("empty backfill should drain");
21652
21653        assert!(subscriber.pending_records.is_empty());
21654        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
21655            .pop()
21656            .unwrap();
21657        assert_eq!(
21658            subscriber.log_anchor(&filter),
21659            Some(42),
21660            "empty window must still seed the anchor at its upper bound"
21661        );
21662    }
21663
21664    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
21665    // the provider head and seeds the anchor there.
21666    #[tokio::test(flavor = "multi_thread")]
21667    #[cfg(feature = "reactive-ws")]
21668    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
21669        let asserter = Asserter::new();
21670        asserter.push_success(&100u64); // get_block_number
21671        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
21672        asserter.push_success(&Vec::<Log>::new()); // get_logs
21673        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
21674        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21675        let mut subscriber = AlloySubscriber::new(
21676            provider,
21677            SubscriberMode::PubSub,
21678            SubscriberConfig::default(),
21679        );
21680        subscriber
21681            .add_interest_owner_with_backfill(
21682                HandlerId::new("pool"),
21683                &[log_interest_matching_rpc_log()],
21684                SubscriberBackfill::from_block(10),
21685            )
21686            .expect("register owner with open-ended backfill");
21687
21688        subscriber
21689            .drain_pending_backfills()
21690            .await
21691            .expect("open-ended backfill should drain");
21692
21693        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
21694            .pop()
21695            .unwrap();
21696        assert_eq!(subscriber.log_anchor(&filter), Some(100));
21697    }
21698
21699    // B2: two owners requesting the same filter shape share exactly one live
21700    // source (and thus one anchor), rather than double-subscribing.
21701    #[test]
21702    #[cfg(feature = "reactive-ws")]
21703    fn duplicate_filters_across_owners_map_to_single_source() {
21704        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21705        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21706            provider,
21707            SubscriberMode::PubSub,
21708            SubscriberConfig::default(),
21709        );
21710        subscriber
21711            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
21712            .expect("register pool-a");
21713        subscriber
21714            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
21715            .expect("register pool-b with identical filter");
21716
21717        assert_eq!(
21718            subscriber.log_stream_filters().len(),
21719            1,
21720            "identical filters across owners must collapse to one"
21721        );
21722        let sources = subscriber.stream_sources().expect("stream sources");
21723        assert_eq!(sources.len(), 1);
21724    }
21725
21726    // B4: removing an owner retires the source-id and anchor bookkeeping for
21727    // filters no other owner references, so long-lived churn cannot leak.
21728    #[test]
21729    #[cfg(feature = "reactive-ws")]
21730    fn owner_removal_prunes_source_ids_and_anchors() {
21731        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21732        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21733            provider,
21734            SubscriberMode::PubSub,
21735            SubscriberConfig::default(),
21736        );
21737        subscriber
21738            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
21739            .expect("register pool-a");
21740        subscriber
21741            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
21742            .expect("register pool-b");
21743
21744        // Allocate ids and simulate delivery anchors on both.
21745        let _ = subscriber.stream_sources().expect("stream sources");
21746        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
21747        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
21748        let id_a = subscriber.log_source_id(&filter_a);
21749        let id_b = subscriber.log_source_id(&filter_b);
21750        subscriber.last_seen_log_blocks.insert(id_a, 10);
21751        subscriber.last_seen_log_blocks.insert(id_b, 20);
21752        assert_eq!(
21753            subscriber.log_source_ids.len(),
21754            3,
21755            "one provider fan-in id plus two explicitly seeded logical ids"
21756        );
21757
21758        subscriber
21759            .remove_interest_owner(&HandlerId::new("pool-b"))
21760            .expect("remove pool-b");
21761
21762        assert_eq!(
21763            subscriber.log_source_ids.len(),
21764            1,
21765            "pool-b's filter id should be retired"
21766        );
21767        assert!(subscriber.log_source_ids.contains_key(&filter_a));
21768        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
21769        assert_eq!(
21770            subscriber.last_seen_log_blocks.get(&id_b),
21771            None,
21772            "pool-b's anchor should be pruned"
21773        );
21774    }
21775
21776    // D1: growing an owner's filter set (a new pool on an existing adapter)
21777    // changes the merged filter shape; the new shape must inherit the old
21778    // anchor via an automatic continuity backfill, or logs between the last
21779    // delivery and the new subscription are silently lost.
21780    #[test]
21781    #[cfg(feature = "reactive-ws")]
21782    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
21783        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21784        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21785            provider,
21786            SubscriberMode::PubSub,
21787            SubscriberConfig::default(),
21788        );
21789        subscriber
21790            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
21791            .expect("register amm with pool A");
21792
21793        // Simulate the owner's single merged filter having delivered up to
21794        // block 50.
21795        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
21796        let id_a = subscriber.log_source_id(&filter_a);
21797        subscriber.last_seen_log_blocks.insert(id_a, 50);
21798
21799        // Grow the owner to also watch pool B (same block option -> merges into
21800        // one {A,B} filter, a new shape).
21801        subscriber
21802            .add_interest_owner(
21803                HandlerId::new("amm"),
21804                &[log_interest_for(0xaa), log_interest_for(0xbb)],
21805            )
21806            .expect("grow amm to pools A+B");
21807
21808        assert_eq!(
21809            subscriber.pending_backfills.len(),
21810            1,
21811            "the changed merged filter should queue exactly one continuity backfill"
21812        );
21813        let queued = &subscriber.pending_backfills[0];
21814        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
21815        assert_eq!(queued.backfill.start_block(), 50);
21816        assert_eq!(
21817            queued.backfill.end_block(),
21818            None,
21819            "continuity backfill runs open-ended to the current head"
21820        );
21821    }
21822
21823    // D1 negative: replacing an owner's interests with the identical shape must
21824    // NOT re-fetch — the filter kept its anchor and its live stream.
21825    #[test]
21826    #[cfg(feature = "reactive-ws")]
21827    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
21828        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21829        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21830            provider,
21831            SubscriberMode::PubSub,
21832            SubscriberConfig::default(),
21833        );
21834        subscriber
21835            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
21836            .expect("register amm");
21837        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
21838        let id_a = subscriber.log_source_id(&filter_a);
21839        subscriber.last_seen_log_blocks.insert(id_a, 50);
21840
21841        subscriber
21842            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
21843            .expect("re-register identical interests");
21844
21845        assert!(
21846            subscriber.pending_backfills.is_empty(),
21847            "an unchanged filter shape must not queue continuity backfill"
21848        );
21849    }
21850
21851    // D5 interaction: an explicit open-ended backfill starting at or below the
21852    // owner's prior anchor already covers the continuity window, so no extra
21853    // continuity backfill is queued (no redundant double fetch).
21854    #[test]
21855    #[cfg(feature = "reactive-ws")]
21856    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
21857        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21858        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21859            provider,
21860            SubscriberMode::PubSub,
21861            SubscriberConfig::default(),
21862        );
21863        subscriber
21864            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
21865            .expect("register amm");
21866        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
21867        let id_a = subscriber.log_source_id(&filter_a);
21868        subscriber.last_seen_log_blocks.insert(id_a, 50);
21869
21870        // Grow with an explicit deep backfill from block 10 (< anchor 50).
21871        subscriber
21872            .add_interest_owner_with_backfill(
21873                HandlerId::new("amm"),
21874                &[log_interest_for(0xaa), log_interest_for(0xbb)],
21875                SubscriberBackfill::from_block(10),
21876            )
21877            .expect("grow amm with explicit deep backfill");
21878
21879        assert_eq!(
21880            subscriber.pending_backfills.len(),
21881            1,
21882            "only the explicit backfill should be queued; continuity is subsumed"
21883        );
21884        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
21885    }
21886
21887    // The dirty flag gates reconciliation: when nothing changed since the last
21888    // reconcile, `ensure_streams` must not touch the provider or the state.
21889    #[tokio::test(flavor = "multi_thread")]
21890    #[cfg(feature = "reactive-ws")]
21891    async fn ensure_streams_is_noop_when_not_dirty() {
21892        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21893        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21894            provider,
21895            SubscriberMode::PubSub,
21896            SubscriberConfig::default(),
21897        );
21898        // An interest that WOULD require a new block-header source...
21899        subscriber
21900            .add_interest_owner(
21901                HandlerId::new("headers"),
21902                &[ReactiveInterest::Blocks(BlockInterest::default())],
21903            )
21904            .expect("register header owner");
21905        // ...but we mark bookkeeping clean and start from Empty.
21906        subscriber.state = AlloySubscriberState::Empty;
21907        subscriber.sources_dirty = false;
21908
21909        subscriber
21910            .ensure_streams()
21911            .await
21912            .expect("clean reconcile must be a no-op");
21913
21914        assert!(
21915            matches!(subscriber.state, AlloySubscriberState::Empty),
21916            "not-dirty ensure_streams must not connect new sources"
21917        );
21918    }
21919}
21920
21921fn resolve_subscriber_transport(
21922    mode: SubscriberMode,
21923) -> Result<SubscriberTransport, SubscriberError> {
21924    match mode {
21925        SubscriberMode::PubSub => {
21926            #[cfg(feature = "reactive-ws")]
21927            {
21928                Ok(SubscriberTransport::PubSub)
21929            }
21930            #[cfg(not(feature = "reactive-ws"))]
21931            {
21932                Err(SubscriberError::Unsupported(
21933                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
21934                ))
21935            }
21936        }
21937        SubscriberMode::Polling => {
21938            #[cfg(feature = "reactive-polling")]
21939            {
21940                Ok(SubscriberTransport::Polling)
21941            }
21942            #[cfg(not(feature = "reactive-polling"))]
21943            {
21944                Err(SubscriberError::Unsupported(
21945                    "AlloySubscriber polling mode requires the reactive-polling feature",
21946                ))
21947            }
21948        }
21949        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
21950    }
21951}
21952
21953fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
21954    #[cfg(feature = "reactive-ws")]
21955    {
21956        Ok(SubscriberTransport::PubSub)
21957    }
21958
21959    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
21960    {
21961        Ok(SubscriberTransport::Polling)
21962    }
21963
21964    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
21965    {
21966        Err(SubscriberError::Unsupported(
21967            "AlloySubscriber requires either reactive-ws or reactive-polling",
21968        ))
21969    }
21970}
21971
21972fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
21973    if config.preconfirmations != PreconfirmationMode::Disabled
21974        && config.canonical_head_poll_interval.is_zero()
21975    {
21976        return Err(SubscriberError::InvalidConfig(
21977            "SubscriberConfig::canonical_head_poll_interval must be greater than zero",
21978        ));
21979    }
21980    if config.preconfirmations != PreconfirmationMode::Disabled
21981        && config.flashblock_poll_interval.is_zero()
21982    {
21983        return Err(SubscriberError::InvalidConfig(
21984            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
21985        ));
21986    }
21987    if config.preconfirmations != PreconfirmationMode::Disabled
21988        && config.max_consecutive_flashblock_poll_failures == 0
21989    {
21990        return Err(SubscriberError::InvalidConfig(
21991            "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
21992        ));
21993    }
21994    if config.preconfirmations != PreconfirmationMode::Disabled
21995        && config.max_pending_transaction_receipts_per_tick == 0
21996    {
21997        return Err(SubscriberError::InvalidConfig(
21998            "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
21999        ));
22000    }
22001    if config.preconfirmations != PreconfirmationMode::Disabled
22002        && config.max_flashblock_rpc_requests_per_second == 0
22003    {
22004        return Err(SubscriberError::InvalidConfig(
22005            "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
22006        ));
22007    }
22008    if config.max_batch_size == 0 {
22009        return Err(SubscriberError::InvalidConfig(
22010            "SubscriberConfig::max_batch_size must be greater than zero",
22011        ));
22012    }
22013    if config.max_log_addresses_per_subscription == 0 {
22014        return Err(SubscriberError::InvalidConfig(
22015            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
22016        ));
22017    }
22018    if config.max_pending_records == 0 {
22019        return Err(SubscriberError::InvalidConfig(
22020            "SubscriberConfig::max_pending_records must be greater than zero",
22021        ));
22022    }
22023    if config.max_pending_backfills == 0 {
22024        return Err(SubscriberError::InvalidConfig(
22025            "SubscriberConfig::max_pending_backfills must be greater than zero",
22026        ));
22027    }
22028    if config.max_backfill_log_bytes == 0 {
22029        return Err(SubscriberError::InvalidConfig(
22030            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
22031        ));
22032    }
22033    if config.max_reconcile_requests_in_flight == 0 {
22034        return Err(SubscriberError::InvalidConfig(
22035            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
22036        ));
22037    }
22038    if config.reconnect.enabled {
22039        if config.reconnect.retry_delay > config.reconnect.max_delay {
22040            return Err(SubscriberError::InvalidConfig(
22041                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
22042            ));
22043        }
22044        if matches!(config.reconnect.max_attempts, Some(0)) {
22045            return Err(SubscriberError::InvalidConfig(
22046                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
22047            ));
22048        }
22049    }
22050    Ok(())
22051}
22052
22053fn validate_supported_interests<N: Network>(
22054    mode: SubscriberMode,
22055    config: &SubscriberConfig,
22056    interests: &[ReactiveInterest<N>],
22057) -> Result<(), SubscriberError> {
22058    let transport = resolve_subscriber_transport(mode)?;
22059
22060    for interest in interests {
22061        match interest {
22062            ReactiveInterest::Logs(_) => {}
22063            ReactiveInterest::PendingTransactions(interest)
22064                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
22065            ReactiveInterest::PendingTransactions(_) => {
22066                return Err(SubscriberError::Unsupported(
22067                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
22068                ));
22069            }
22070            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
22071                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
22072                (_, BlockInterestMode::FullBlock) => {
22073                    return Err(SubscriberError::Unsupported(
22074                        "AlloySubscriber full block streams are not implemented in this transport slice",
22075                    ));
22076                }
22077                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
22078                    return Err(SubscriberError::Unsupported(
22079                        "AlloySubscriber polling block streams are not implemented in this transport slice",
22080                    ));
22081                }
22082            },
22083        }
22084    }
22085
22086    Ok(())
22087}
22088
22089fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
22090    let mut filters = Vec::new();
22091    for interest in interests {
22092        if let ReactiveInterest::Logs(interest) = interest {
22093            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
22094        }
22095    }
22096    filters
22097}
22098
22099fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
22100    interests.iter().any(|interest| {
22101        matches!(
22102            interest,
22103            ReactiveInterest::Blocks(BlockInterest {
22104                mode: BlockInterestMode::Header,
22105            })
22106        )
22107    })
22108}
22109
22110fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
22111    interests.iter().any(|interest| {
22112        matches!(
22113            interest,
22114            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
22115        )
22116    })
22117}
22118
22119fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
22120    interests.iter().any(|interest| {
22121        matches!(
22122            interest,
22123            ReactiveInterest::Logs(interest) if interest.matches(log)
22124        )
22125    })
22126}
22127
22128fn validate_owner_backfill_logs(
22129    logs: &[Log],
22130    from_block: u64,
22131    through: &BlockRef,
22132) -> Result<(), SubscriberOwnerError> {
22133    for log in logs {
22134        if log.removed {
22135            return Err(SubscriberOwnerError::InvalidBackfillLog(
22136                "removed log in canonical catch-up",
22137            ));
22138        }
22139        let number = log
22140            .block_number
22141            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
22142                "log missing block number",
22143            ))?;
22144        let hash = log
22145            .block_hash
22146            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
22147                "log missing block hash",
22148            ))?;
22149        log.transaction_hash
22150            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
22151                "log missing transaction hash",
22152            ))?;
22153        log.transaction_index
22154            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
22155                "log missing transaction index",
22156            ))?;
22157        log.log_index
22158            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
22159                "log missing log index",
22160            ))?;
22161        if number < from_block || number > through.number {
22162            return Err(SubscriberOwnerError::InvalidBackfillLog(
22163                "log outside requested block range",
22164            ));
22165        }
22166        if number == through.number && hash != through.hash {
22167            return Err(SubscriberOwnerError::InvalidBackfillLog(
22168                "target-block log hash mismatch",
22169            ));
22170        }
22171    }
22172    Ok(())
22173}
22174
22175fn validate_backfill_resource_limits(
22176    logs: &[Log],
22177    max_logs: usize,
22178    max_log_bytes: usize,
22179) -> Result<usize, SubscriberError> {
22180    if logs.len() > max_logs {
22181        return Err(SubscriberError::ResourceExhausted(format!(
22182            "historical response returned {} logs, above the configured limit of {max_logs}",
22183            logs.len()
22184        )));
22185    }
22186    let bytes = logs.iter().fold(0usize, |total, log| {
22187        // Include fixed address/block/transaction/index fields in addition to
22188        // the variable topic and data payload. This is deliberately a stable
22189        // conservative accounting unit rather than Rust heap-layout size.
22190        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
22191        total
22192            .saturating_add(fixed)
22193            .saturating_add(log.topics().len().saturating_mul(32))
22194            .saturating_add(log.inner.data.data.len())
22195    });
22196    if bytes > max_log_bytes {
22197        return Err(SubscriberError::ResourceExhausted(format!(
22198            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
22199        )));
22200    }
22201    Ok(bytes)
22202}
22203
22204async fn fetch_provider_block_ref<P, N>(
22205    provider: &P,
22206    number: u64,
22207) -> Result<BlockRef, SubscriberError>
22208where
22209    P: Provider<N> + Send + Sync,
22210    N: Network,
22211{
22212    let block = provider
22213        .get_block_by_number(BlockNumberOrTag::Number(number))
22214        .await
22215        .map_err(provider_error)?
22216        .ok_or_else(|| {
22217            SubscriberError::InvalidBackfill(format!(
22218                "canonical target block {number} is unavailable"
22219            ))
22220        })?;
22221    let header = block.header();
22222    Ok(BlockRef {
22223        number: header.number(),
22224        hash: header.hash(),
22225        parent_hash: Some(header.parent_hash()),
22226        timestamp: Some(header.timestamp()),
22227    })
22228}
22229
22230fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
22231    actual.number == expected.number
22232        && actual.hash == expected.hash
22233        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
22234        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
22235}
22236
22237fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
22238    let mut positions = HashMap::new();
22239    let mut block_hashes = HashMap::new();
22240    let mut transaction_hashes = HashMap::new();
22241    let mut transaction_positions = HashMap::new();
22242    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
22243    for log in logs {
22244        let number = log
22245            .block_number
22246            .expect("individual owner catch-up logs are validated before set validation");
22247        let block_hash = log
22248            .block_hash
22249            .expect("individual owner catch-up logs are validated before set validation");
22250        let transaction_hash = log
22251            .transaction_hash
22252            .expect("individual owner catch-up logs are validated before set validation");
22253        let transaction_index = log
22254            .transaction_index
22255            .expect("individual owner catch-up logs are validated before set validation");
22256        let log_index = log
22257            .log_index
22258            .expect("individual owner catch-up logs are validated before set validation");
22259        if block_hashes
22260            .insert(number, block_hash)
22261            .is_some_and(|prior| prior != block_hash)
22262        {
22263            return Err(SubscriberOwnerError::InvalidBackfillLog(
22264                "conflicting block identity in canonical catch-up",
22265            ));
22266        }
22267        if let Some(previous) = positions.insert((number, log_index), log)
22268            && previous != log
22269        {
22270            return Err(SubscriberOwnerError::InvalidBackfillLog(
22271                "conflicting logs at one canonical block position",
22272            ));
22273        }
22274        let conflicting_transaction = transaction_hashes
22275            .insert((number, transaction_index), transaction_hash)
22276            .is_some_and(|prior| prior != transaction_hash)
22277            || transaction_positions
22278                .insert((number, transaction_hash), transaction_index)
22279                .is_some_and(|prior| prior != transaction_index);
22280        if conflicting_transaction {
22281            return Err(SubscriberOwnerError::InvalidBackfillLog(
22282                "conflicting transaction identity at one canonical block position",
22283            ));
22284        }
22285        ordering
22286            .entry(number)
22287            .or_default()
22288            .push((log_index, transaction_index));
22289    }
22290    for positions in ordering.values_mut() {
22291        positions.sort_unstable();
22292        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
22293            return Err(SubscriberOwnerError::InvalidBackfillLog(
22294                "transaction and log positions disagree on canonical order",
22295            ));
22296        }
22297    }
22298    Ok(())
22299}
22300
22301fn merged_owner_reconcile_filters<N: Network>(
22302    plans: &[SubscriberOwnerReconcilePlan<N>],
22303    through: u64,
22304) -> Vec<SubscriberOwnerReconcileFilter> {
22305    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
22306    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
22307        let filters = by_start.entry(plan.from_block).or_default();
22308        filters.extend(
22309            log_filters(&plan.interests)
22310                .into_iter()
22311                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
22312        );
22313    }
22314
22315    let mut chunks = Vec::new();
22316    for (from_block, filters) in by_start {
22317        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
22318            let mut merged = Vec::new();
22319            for filter in filters {
22320                merge_log_subscription_filter(&mut merged, filter);
22321            }
22322            chunks.extend(
22323                merged
22324                    .into_iter()
22325                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
22326            );
22327        }
22328    }
22329    chunks
22330}
22331
22332fn merged_lazy_backfill_filters(
22333    filters: &[Filter],
22334    from_block: u64,
22335    through: u64,
22336) -> Vec<SubscriberOwnerReconcileFilter> {
22337    let mut requests = Vec::new();
22338    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
22339        let mut merged = Vec::new();
22340        for filter in filters {
22341            merge_log_subscription_filter(
22342                &mut merged,
22343                &filter.clone().from_block(from_block).to_block(through),
22344            );
22345        }
22346        requests.extend(
22347            merged
22348                .into_iter()
22349                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
22350        );
22351    }
22352    requests
22353}
22354
22355fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
22356    match error {
22357        SubscriberOwnerError::Subscriber(error) => error,
22358        error => SubscriberError::InvalidBackfill(error.to_string()),
22359    }
22360}
22361
22362fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
22363    let mut id = b"alloy-global-backfill-v1".to_vec();
22364    id.extend_from_slice(&backfill.start_block().to_be_bytes());
22365    id.extend_from_slice(&certified.number.to_be_bytes());
22366    id.extend_from_slice(certified.hash.as_slice());
22367    ChainControl::Barrier {
22368        id,
22369        block: Some(certified),
22370    }
22371}
22372
22373async fn fetch_owner_catchup<P, N>(
22374    provider: P,
22375    filters: Vec<SubscriberOwnerReconcileFilter>,
22376    retained: Vec<BlockRef>,
22377    through: BlockRef,
22378    options: SubscriberOwnerCatchupOptions,
22379) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
22380where
22381    P: Provider<N> + Send + Sync,
22382    N: Network,
22383{
22384    if !options.target_preverified {
22385        let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
22386    }
22387    let mut certified_positions = HashSet::new();
22388    for position in retained {
22389        let target_certifies_position = position == through
22390            || (position.number.checked_add(1) == Some(through.number)
22391                && through.parent_hash == Some(position.hash));
22392        if !target_certifies_position && certified_positions.insert(position) {
22393            let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
22394        }
22395    }
22396    let mut logs = Vec::new();
22397    let mut total_log_bytes = 0usize;
22398    let requests = stream::iter(filters.into_iter().map(|filter| {
22399        let provider = &provider;
22400        async move {
22401            let logs = provider
22402                .get_logs(&filter.filter)
22403                .await
22404                .map_err(provider_error)?;
22405            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
22406        }
22407    }))
22408    .buffer_unordered(options.max_requests_in_flight);
22409    futures::pin_mut!(requests);
22410    while let Some(result) = requests.next().await {
22411        let (from_block, fetched) = result?;
22412        let fetched_bytes =
22413            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
22414        validate_owner_backfill_logs(&fetched, from_block, &through)?;
22415        if logs.len().saturating_add(fetched.len()) > options.max_logs {
22416            return Err(SubscriberError::ResourceExhausted(format!(
22417                "bulk reconcile returned more than {} logs",
22418                options.max_logs
22419            ))
22420            .into());
22421        }
22422        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
22423        if total_log_bytes > options.max_log_bytes {
22424            return Err(SubscriberError::ResourceExhausted(format!(
22425                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
22426                options.max_log_bytes
22427            ))
22428            .into());
22429        }
22430        logs.extend(fetched);
22431    }
22432    validate_owner_backfill_log_set(&logs)?;
22433    let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
22434    Ok(SubscriberOwnerCatchup { logs, certified })
22435}
22436
22437async fn verify_provider_reconcile_target<P, N>(
22438    provider: &P,
22439    expected: &BlockRef,
22440) -> Result<BlockRef, SubscriberOwnerError>
22441where
22442    P: Provider<N> + Send + Sync,
22443    N: Network,
22444{
22445    let block = provider
22446        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
22447        .await
22448        .map_err(provider_error)?
22449        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
22450    let header = block.header();
22451    let actual = BlockRef {
22452        number: header.number(),
22453        hash: header.hash(),
22454        parent_hash: Some(header.parent_hash()),
22455        timestamp: Some(header.timestamp()),
22456    };
22457    let exact_parent = expected
22458        .parent_hash
22459        .is_none_or(|parent| Some(parent) == actual.parent_hash);
22460    let exact_timestamp = expected
22461        .timestamp
22462        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
22463    if actual.number != expected.number
22464        || actual.hash != expected.hash
22465        || !exact_parent
22466        || !exact_timestamp
22467    {
22468        return Err(SubscriberOwnerError::BlockMismatch {
22469            expected_number: expected.number,
22470            expected_hash: expected.hash,
22471            actual_number: actual.number,
22472            actual_hash: actual.hash,
22473        });
22474    }
22475    Ok(actual)
22476}
22477
22478fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
22479    let context = log_reactive_context(&log);
22480    ReactiveInputRecord::new(
22481        ReactiveInput::Log(log),
22482        ReactiveContext { source, ..context },
22483    )
22484}
22485
22486fn preconfirmed_log_input_record<N: Network>(
22487    log: Log,
22488    flashblock: FlashblockRef,
22489) -> ReactiveInputRecord<N> {
22490    let block = flashblock.block_ref();
22491    let provider = flashblock.provider.clone();
22492    ReactiveInputRecord::new(
22493        ReactiveInput::Log(log.clone()),
22494        ReactiveContext {
22495            chain_id: None,
22496            source: InputSource::Flashblocks,
22497            chain_status: ChainStatus::Preconfirmed {
22498                flashblock: Arc::new(flashblock),
22499            },
22500            block: Some(block),
22501            transaction_index: log.transaction_index,
22502            log_index: log.log_index,
22503        },
22504    )
22505    .with_provider(provider)
22506}
22507
22508fn log_reactive_context(log: &Log) -> ReactiveContext {
22509    let block = match (log.block_hash, log.block_number) {
22510        (Some(hash), Some(number)) => Some(BlockRef {
22511            number,
22512            hash,
22513            parent_hash: None,
22514            timestamp: log.block_timestamp,
22515        }),
22516        _ => None,
22517    };
22518
22519    let chain_status = match (&block, log.removed) {
22520        (Some(block), true) => ChainStatus::Reorged {
22521            dropped_from: *block,
22522        },
22523        (Some(block), false) => ChainStatus::Included {
22524            block: *block,
22525            confirmations: 0,
22526        },
22527        (None, _) => ChainStatus::Pending,
22528    };
22529
22530    ReactiveContext {
22531        chain_id: None,
22532        source: InputSource::Poll,
22533        chain_status,
22534        block,
22535        transaction_index: log.transaction_index,
22536        log_index: log.log_index,
22537    }
22538}
22539
22540fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
22541where
22542    N: Network,
22543{
22544    let block = BlockRef {
22545        number: header.number(),
22546        hash: HeaderResponseTrait::hash(&header),
22547        parent_hash: Some(header.parent_hash()),
22548        timestamp: Some(header.timestamp()),
22549    };
22550    ReactiveInputRecord::new(
22551        ReactiveInput::BlockHeader(header),
22552        ReactiveContext {
22553            chain_id: None,
22554            source: InputSource::Subscription,
22555            chain_status: ChainStatus::Included {
22556                block,
22557                confirmations: 0,
22558            },
22559            block: Some(block),
22560            transaction_index: None,
22561            log_index: None,
22562        },
22563    )
22564}
22565
22566fn pending_hash_input_record<N: Network>(
22567    hash: B256,
22568    source: InputSource,
22569) -> ReactiveInputRecord<N> {
22570    ReactiveInputRecord::new(
22571        ReactiveInput::PendingTxHash(hash),
22572        ReactiveContext {
22573            chain_id: None,
22574            source,
22575            chain_status: ChainStatus::Pending,
22576            block: None,
22577            transaction_index: None,
22578            log_index: None,
22579        },
22580    )
22581}
22582
22583#[cfg(feature = "reactive-ws")]
22584fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
22585    let encoded = serde_json::to_value(filter)
22586        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
22587    let serde_json::Value::Object(mut fields) = encoded else {
22588        return Err(SubscriberError::Provider(
22589            "Alloy log filter did not serialize as an object".into(),
22590        ));
22591    };
22592    fields.retain(|key, _| key == "address" || key == "topics");
22593    Ok(serde_json::Value::Object(fields))
22594}
22595
22596fn provider_error(error: impl fmt::Display) -> SubscriberError {
22597    SubscriberError::Provider(error.to_string())
22598}
22599
22600/// Subscriber error.
22601#[derive(Debug, thiserror::Error)]
22602#[non_exhaustive]
22603pub enum SubscriberError {
22604    /// Invalid subscriber configuration.
22605    #[error("{0}")]
22606    InvalidConfig(&'static str),
22607    /// Requested subscriber behavior is not implemented.
22608    #[error("{0}")]
22609    Unsupported(&'static str),
22610    /// The pinned provider lease reports a different chain identity.
22611    #[error("subscriber chain mismatch: expected {expected}, got {actual}")]
22612    ChainMismatch {
22613        /// Required chain id.
22614        expected: u64,
22615        /// Observed chain id.
22616        actual: u64,
22617    },
22618    /// Provider or transport error.
22619    #[error("provider error: {0}")]
22620    Provider(String),
22621    /// A provider returned malformed, out-of-range, or non-canonical lazy
22622    /// backfill data.
22623    #[error("invalid canonical backfill: {0}")]
22624    InvalidBackfill(String),
22625    /// A configured subscriber memory/concurrency boundary was exceeded.
22626    #[error("subscriber resource limit exceeded: {0}")]
22627    ResourceExhausted(String),
22628}