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#[cfg(feature = "raw-flashblocks-json")]
66mod raw_json_flashblocks;
67#[cfg(feature = "raw-flashblocks-json")]
68pub use raw_json_flashblocks::{
69    BufferedRawJsonFlashblocksAdapter, FlashblockInvalidation, FlashblockInvalidationReason,
70    FlashblockSnapshot, FlashblockUpdate, FlashblockUpdateAcknowledgement,
71    FlashblockUpdateChannelError, FlashblockUpdateSender, RawJsonFlashblocksAdapter,
72    RawJsonFlashblocksError, RawJsonFlashblocksLimits, TimedFlashblockUpdate,
73};
74
75/// Input accepted by the reactive runtime.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub enum ReactiveInput<N: Network = Ethereum> {
78    /// A canonical or removed EVM log, using Alloy's RPC log type.
79    Log(Log),
80    /// A block header response for header-oriented handlers.
81    BlockHeader(N::HeaderResponse),
82    /// A full block response for block handlers that need transaction bodies.
83    FullBlock(N::BlockResponse),
84    /// A pending transaction hash.
85    PendingTxHash(B256),
86    /// A full pending transaction body.
87    PendingTx(N::TransactionResponse),
88}
89
90/// Context supplied with each [`ReactiveInput`].
91#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub struct ReactiveContext {
93    /// Chain id, when known.
94    pub chain_id: Option<u64>,
95    /// Where the input came from.
96    pub source: InputSource,
97    /// Lifecycle status of the input.
98    pub chain_status: ChainStatus,
99    /// Block metadata associated with the input, when known.
100    pub block: Option<BlockRef>,
101    /// Transaction index for log or transaction inputs.
102    pub transaction_index: Option<u64>,
103    /// Log index for log inputs.
104    pub log_index: Option<u64>,
105}
106
107/// Minimal block identity carried through reports.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
109pub struct BlockRef {
110    /// Block number.
111    pub number: u64,
112    /// Block hash.
113    pub hash: B256,
114    /// Parent hash, when known.
115    pub parent_hash: Option<B256>,
116    /// Block timestamp, when known.
117    pub timestamp: Option<u64>,
118}
119
120/// Stable provider identity attached to provider-originated input.
121///
122/// `generation` changes whenever a caller replaces or reconnects the concrete
123/// provider session behind the same configured endpoint. Follow-up reads can
124/// use this value to prefer the exact source that announced speculative state
125/// without putting URLs or credentials into event payloads.
126#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
127pub struct ProviderRef {
128    /// Operator-defined endpoint identity.
129    pub endpoint: EndpointId,
130    /// Concrete connection/session generation.
131    pub generation: u64,
132}
133
134impl ProviderRef {
135    /// Construct provider provenance for one connection generation.
136    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
137        Self {
138            endpoint: endpoint.into(),
139            generation,
140        }
141    }
142}
143
144/// Process-local monotonic time at which a Flashblock source item first
145/// entered the typed subscriber boundary.
146///
147/// This metadata never participates in Flashblock identity, ordering,
148/// canonical state, or execution authority.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub struct FlashblockIngressTiming {
151    source_ingress: Instant,
152}
153
154impl FlashblockIngressTiming {
155    /// Bind a source item to its earliest process-local typed arrival.
156    pub const fn new(source_ingress: Instant) -> Self {
157        Self { source_ingress }
158    }
159
160    /// Earliest process-local typed arrival for the source item.
161    pub const fn source_ingress(self) -> Instant {
162        self.source_ingress
163    }
164
165    /// Retain the earliest contributing source arrival.
166    pub fn earliest(self, other: Self) -> Self {
167        Self::new(self.source_ingress.min(other.source_ingress))
168    }
169}
170
171/// Identity of one cumulative pre-confirmed Flashblock snapshot.
172#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
173pub struct FlashblockRef {
174    /// Provider session that supplied this snapshot.
175    pub provider: ProviderRef,
176    /// Sequencer payload id shared by every Flashblock in the full block.
177    ///
178    /// Some provider wire shapes omit this indexed-payload identifier.
179    pub payload_id: Option<FixedBytes<8>>,
180    /// Zero-based Flashblock index, when exposed by the endpoint.
181    pub index: Option<u64>,
182    /// Pending block number represented by this cumulative snapshot.
183    pub block_number: u64,
184    /// Provider-generation-scoped commitment to this exact cumulative view.
185    ///
186    /// This is deliberately not a canonical or provider-reported block hash.
187    /// It remains non-zero even when a pending endpoint uses the zero hash
188    /// placeholder permitted by the Flashblocks specification.
189    pub content_hash: B256,
190    /// Non-placeholder partial block hash reported by the provider, when any.
191    pub partial_block_hash: Option<B256>,
192    /// Canonical parent of the pending block, when exposed.
193    pub parent_hash: Option<B256>,
194    /// State root after this cumulative snapshot, when exposed.
195    pub state_root: Option<B256>,
196    /// Transaction-trie root committed by a cumulative block-shaped preview.
197    pub transactions_root: Option<B256>,
198    /// Ordered cumulative transaction membership for this preview.
199    pub transaction_hashes: Vec<B256>,
200    /// Pending block timestamp, when exposed.
201    pub timestamp: Option<u64>,
202    /// Pending EIP-1559 base fee, when exposed.
203    pub base_fee_per_gas: Option<u64>,
204    /// Pending block beneficiary / fee recipient, when exposed.
205    pub beneficiary: Option<Address>,
206    /// Pending block randomness value, when exposed.
207    pub prevrandao: Option<B256>,
208    /// Pending block gas limit, when exposed.
209    pub gas_limit: Option<u64>,
210}
211
212impl FlashblockRef {
213    /// Convert the pre-confirmed identity into the block metadata used by
214    /// ordinary log routing. The hash is the provider-generation-scoped
215    /// [`content_hash`](Self::content_hash), never a canonical block hash, and
216    /// must not advance canonical coverage.
217    pub const fn block_ref(&self) -> BlockRef {
218        BlockRef {
219            number: self.block_number,
220            hash: self.content_hash,
221            parent_hash: self.parent_hash,
222            timestamp: self.timestamp,
223        }
224    }
225
226    /// Whether the cumulative preview contains `transaction_hash`.
227    pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
228        self.transaction_hashes.contains(transaction_hash)
229    }
230
231    fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
232        self.transaction_hashes
233            .iter()
234            .position(|candidate| candidate == transaction_hash)
235            .and_then(|index| u64::try_from(index).ok())
236    }
237
238    fn same_payload(&self, other: &Self) -> bool {
239        self.provider == other.provider
240            && match (self.payload_id, other.payload_id) {
241                (Some(left), Some(right)) => left == right,
242                _ => {
243                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
244                }
245            }
246    }
247
248    /// Whether two cumulative previews bind the same pending-block base
249    /// fields, excluding payload index, cumulative transactions, and derived
250    /// content commitments.
251    ///
252    /// This provider-free predicate lets applications retain lineage metadata
253    /// only across snapshots that cannot have crossed a pending-block
254    /// replacement boundary. It grants no canonical or execution authority.
255    #[cfg(feature = "raw-flashblocks-json")]
256    pub fn same_base_identity(&self, other: &Self) -> bool {
257        self.block_number == other.block_number
258            && self.parent_hash == other.parent_hash
259            && self.timestamp == other.timestamp
260            && self.base_fee_per_gas == other.base_fee_per_gas
261            && self.beneficiary == other.beneficiary
262            && self.prevrandao == other.prevrandao
263            && self.gas_limit == other.gas_limit
264    }
265
266    fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
267        self.same_payload(previous)
268            && self.transaction_hashes.len() >= previous.transaction_hashes.len()
269            && self
270                .transaction_hashes
271                .starts_with(&previous.transaction_hashes)
272            && match (previous.index, self.index) {
273                (Some(previous), Some(current)) => current >= previous,
274                _ => true,
275            }
276    }
277}
278
279/// Whether the subscriber may use Flashblocks for speculative delivery.
280#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
281pub enum PreconfirmationMode {
282    /// Use only canonical subscription/polling behavior.
283    #[default]
284    Disabled,
285    /// Prefer Flashblocks, but retain canonical operation when the selected
286    /// chain/provider cannot establish the pre-confirmation stream.
287    Preferred,
288    /// Fail setup/reconnect closed unless Flashblocks can be established.
289    Required,
290}
291
292/// Indexed OP Stack `newFlashblocks` subscription payload.
293#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
294pub struct BaseFlashblockPayload {
295    /// Block-builder payload id shared by every incremental snapshot.
296    pub payload_id: FixedBytes<8>,
297    /// Zero-based incremental snapshot index.
298    pub index: u64,
299    /// Header fields present on index zero.
300    pub base: Option<BaseFlashblockBase>,
301    /// Cumulative state commitments for this snapshot.
302    pub diff: BaseFlashblockDiff,
303    /// Supplemental block identity retained across current Base versions.
304    #[serde(default)]
305    pub metadata: Option<BaseFlashblockMetadata>,
306}
307
308/// Stable index-zero header subset from Base's Flashblocks wire format.
309#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
310pub struct BaseFlashblockBase {
311    /// Canonical parent block hash.
312    pub parent_hash: B256,
313    /// Pending block number.
314    #[serde(deserialize_with = "deserialize_rpc_u64")]
315    pub block_number: u64,
316    /// Pending block timestamp.
317    #[serde(deserialize_with = "deserialize_rpc_u64")]
318    pub timestamp: u64,
319    /// Pending block gas limit.
320    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
321    pub gas_limit: Option<u64>,
322    /// Pending EIP-1559 base fee.
323    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
324    pub base_fee_per_gas: Option<u64>,
325    /// Pending block beneficiary / fee recipient.
326    #[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
327    pub beneficiary: Option<Address>,
328    /// Pending block randomness value.
329    #[serde(
330        default,
331        alias = "prev_randao",
332        alias = "prevRandao",
333        alias = "mixHash"
334    )]
335    pub prevrandao: Option<B256>,
336}
337
338/// Stable commitment subset from Base's Flashblocks wire format.
339#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
340pub struct BaseFlashblockDiff {
341    /// State root after this cumulative snapshot.
342    pub state_root: B256,
343    /// Partial block hash after this cumulative snapshot.
344    pub block_hash: B256,
345    /// Transactions added by this indexed Flashblock diff.
346    #[serde(default)]
347    pub transactions: Vec<serde_json::Value>,
348    /// Transaction root when exposed by the provider.
349    #[serde(default)]
350    pub transactions_root: Option<B256>,
351}
352
353/// Stable metadata subset used when index-greater-than-zero payloads omit the
354/// Base header object.
355#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
356pub struct BaseFlashblockMetadata {
357    /// Pending block number (currently encoded as a JSON integer).
358    #[serde(deserialize_with = "deserialize_rpc_u64")]
359    pub block_number: u64,
360}
361
362/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack
363/// providers.
364#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
365#[serde(rename_all = "camelCase")]
366struct BaseFlashblockBlockPayload {
367    hash: B256,
368    #[serde(deserialize_with = "deserialize_rpc_u64")]
369    number: u64,
370    parent_hash: B256,
371    state_root: B256,
372    #[serde(default)]
373    transactions_root: Option<B256>,
374    #[serde(default)]
375    transactions: Vec<serde_json::Value>,
376    #[serde(deserialize_with = "deserialize_rpc_u64")]
377    timestamp: u64,
378    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
379    base_fee_per_gas: Option<u64>,
380    #[serde(default, alias = "beneficiary", alias = "feeRecipient")]
381    miner: Option<Address>,
382    #[serde(default, alias = "prevRandao")]
383    mix_hash: Option<B256>,
384    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
385    gas_limit: Option<u64>,
386}
387
388/// OP Stack providers expose either an indexed diff envelope or a cumulative
389/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
390/// differences do not force callers onto separate subscriber paths.
391#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
392#[serde(untagged)]
393enum BaseFlashblockWirePayload {
394    Indexed(BaseFlashblockPayload),
395    Block(BaseFlashblockBlockPayload),
396}
397
398fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
399where
400    D: serde::Deserializer<'de>,
401{
402    #[derive(serde::Deserialize)]
403    #[serde(untagged)]
404    enum RpcU64 {
405        Number(u64),
406        String(String),
407    }
408
409    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
410        RpcU64::Number(number) => Ok(number),
411        RpcU64::String(value) => {
412            let value = value.strip_prefix("0x").unwrap_or(&value);
413            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
414        }
415    }
416}
417
418fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
419where
420    D: serde::Deserializer<'de>,
421{
422    #[derive(serde::Deserialize)]
423    #[serde(untagged)]
424    enum RpcU64 {
425        Number(u64),
426        String(String),
427    }
428
429    let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
430        return Ok(None);
431    };
432    match value {
433        RpcU64::Number(number) => Ok(Some(number)),
434        RpcU64::String(value) => {
435            let value = value.strip_prefix("0x").unwrap_or(&value);
436            u64::from_str_radix(value, 16)
437                .map(Some)
438                .map_err(serde::de::Error::custom)
439        }
440    }
441}
442
443fn non_placeholder_hash(hash: B256) -> Option<B256> {
444    (!hash.is_zero()).then_some(hash)
445}
446
447fn flashblock_transaction_hashes(
448    transactions: &[serde_json::Value],
449) -> Result<Vec<B256>, SubscriberError> {
450    let hashes: Vec<B256> = transactions
451        .iter()
452        .map(|transaction| {
453            let value = match transaction {
454                serde_json::Value::String(value) => value.as_str(),
455                serde_json::Value::Object(object) => object
456                    .get("hash")
457                    .or_else(|| object.get("transactionHash"))
458                    .and_then(serde_json::Value::as_str)
459                    .ok_or_else(|| {
460                        SubscriberError::Provider(
461                            "Flashblock transaction object is missing its hash".into(),
462                        )
463                    })?,
464                _ => {
465                    return Err(SubscriberError::Provider(
466                        "Flashblock transaction must be a hash, raw transaction, or object".into(),
467                    ));
468                }
469            };
470            if value.len() == 66 {
471                return value.parse::<B256>().map_err(|error| {
472                    SubscriberError::Provider(format!(
473                        "Flashblock transaction hash is invalid: {error}"
474                    ))
475                });
476            }
477            let encoded = value.strip_prefix("0x").unwrap_or(value);
478            let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
479                SubscriberError::Provider(format!(
480                    "Flashblock raw transaction is invalid hex: {error}"
481                ))
482            })?;
483            Ok(alloy_primitives::keccak256(raw))
484        })
485        .collect::<Result<_, _>>()?;
486    let mut unique = HashSet::with_capacity(hashes.len());
487    if hashes.iter().any(|hash| !unique.insert(*hash)) {
488        return Err(SubscriberError::Provider(
489            "Flashblock cumulative transaction membership contains a duplicate hash".into(),
490        ));
491    }
492    Ok(hashes)
493}
494
495struct FlashblockContentCommitment<'a> {
496    provider: &'a ProviderRef,
497    payload_id: Option<FixedBytes<8>>,
498    index: Option<u64>,
499    block_number: u64,
500    partial_block_hash: Option<B256>,
501    parent_hash: Option<B256>,
502    state_root: Option<B256>,
503    transactions_root: Option<B256>,
504    transaction_hashes: &'a [B256],
505    timestamp: Option<u64>,
506    base_fee_per_gas: Option<u64>,
507    beneficiary: Option<Address>,
508    prevrandao: Option<B256>,
509    gas_limit: Option<u64>,
510}
511
512fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
513    let mut commitment = Keccak256::new();
514    commitment.update(b"evm-fork-cache/flashblock-content/v1");
515    let endpoint = content.provider.endpoint.as_str().as_bytes();
516    commitment.update((endpoint.len() as u64).to_be_bytes());
517    commitment.update(endpoint);
518    commitment.update(content.provider.generation.to_be_bytes());
519    commitment.update(content.block_number.to_be_bytes());
520    commit_optional_bytes(
521        &mut commitment,
522        content.payload_id.as_ref().map(FixedBytes::as_slice),
523    );
524    commit_optional_u64(&mut commitment, content.index);
525    commit_optional_bytes(
526        &mut commitment,
527        content
528            .partial_block_hash
529            .as_ref()
530            .map(FixedBytes::as_slice),
531    );
532    commit_optional_bytes(
533        &mut commitment,
534        content.parent_hash.as_ref().map(FixedBytes::as_slice),
535    );
536    commit_optional_bytes(
537        &mut commitment,
538        content.state_root.as_ref().map(FixedBytes::as_slice),
539    );
540    commit_optional_bytes(
541        &mut commitment,
542        content.transactions_root.as_ref().map(FixedBytes::as_slice),
543    );
544    commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
545    for transaction_hash in content.transaction_hashes {
546        commitment.update(transaction_hash);
547    }
548    commit_optional_u64(&mut commitment, content.timestamp);
549    commit_optional_u64(&mut commitment, content.base_fee_per_gas);
550    commit_optional_bytes(
551        &mut commitment,
552        content
553            .beneficiary
554            .as_ref()
555            .map(|address| address.as_slice()),
556    );
557    commit_optional_bytes(
558        &mut commitment,
559        content.prevrandao.as_ref().map(FixedBytes::as_slice),
560    );
561    commit_optional_u64(&mut commitment, content.gas_limit);
562    let hash = commitment.finalize();
563    if hash.is_zero() {
564        B256::with_last_byte(1)
565    } else {
566        hash
567    }
568}
569
570#[cfg(feature = "raw-flashblocks-json")]
571fn validate_standard_flashblock_snapshot(
572    snapshot: &FlashblockSnapshot,
573) -> Result<(), SubscriberError> {
574    let flashblock = &snapshot.flashblock;
575    if flashblock.payload_id.is_none() || flashblock.index.is_none() {
576        return Err(SubscriberError::Provider(
577            "external Flashblock snapshot is missing its indexed payload identity".into(),
578        ));
579    }
580    let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment {
581        provider: &flashblock.provider,
582        payload_id: flashblock.payload_id,
583        index: flashblock.index,
584        block_number: flashblock.block_number,
585        partial_block_hash: flashblock.partial_block_hash,
586        parent_hash: flashblock.parent_hash,
587        state_root: flashblock.state_root,
588        transactions_root: flashblock.transactions_root,
589        transaction_hashes: &flashblock.transaction_hashes,
590        timestamp: flashblock.timestamp,
591        base_fee_per_gas: flashblock.base_fee_per_gas,
592        beneficiary: flashblock.beneficiary,
593        prevrandao: flashblock.prevrandao,
594        gas_limit: flashblock.gas_limit,
595    });
596    if flashblock.content_hash != expected_content_hash {
597        return Err(SubscriberError::Provider(
598            "external Flashblock content commitment is invalid".into(),
599        ));
600    }
601
602    let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len());
603    if flashblock
604        .transaction_hashes
605        .iter()
606        .any(|hash| !transactions.insert(*hash))
607    {
608        return Err(SubscriberError::Provider(
609            "external Flashblock cumulative transaction membership contains a duplicate hash"
610                .into(),
611        ));
612    }
613
614    let mut log_ids = HashSet::with_capacity(snapshot.logs.len());
615    for log in &snapshot.logs {
616        if log.removed || log.block_number != Some(flashblock.block_number) {
617            return Err(SubscriberError::Provider(
618                "external pre-confirmed log disagrees with its Flashblock block identity".into(),
619            ));
620        }
621        if log.block_hash != Some(flashblock.content_hash) {
622            return Err(SubscriberError::Provider(
623                "external pre-confirmed log is not bound to its Flashblock content commitment"
624                    .into(),
625            ));
626        }
627        let transaction_hash = log.transaction_hash.ok_or_else(|| {
628            SubscriberError::Provider(
629                "external pre-confirmed log is missing its transaction hash".into(),
630            )
631        })?;
632        let expected_transaction_index = flashblock
633            .transaction_index(&transaction_hash)
634            .ok_or_else(|| {
635                SubscriberError::Provider(
636                    "external pre-confirmed log transaction is absent from the cumulative Flashblock"
637                        .into(),
638                )
639            })?;
640        if log.transaction_index != Some(expected_transaction_index) {
641            return Err(SubscriberError::Provider(
642                "external pre-confirmed log transaction index disagrees with cumulative membership"
643                    .into(),
644            ));
645        }
646        let log_index = log.log_index.ok_or_else(|| {
647            SubscriberError::Provider("external pre-confirmed log is missing its log index".into())
648        })?;
649        if !log_ids.insert((transaction_hash, log_index)) {
650            return Err(SubscriberError::Provider(
651                "external Flashblock snapshot contains a duplicate log identity".into(),
652            ));
653        }
654    }
655    Ok(())
656}
657
658fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
659    match value {
660        Some(value) => {
661            commitment.update([1]);
662            commitment.update((value.len() as u64).to_be_bytes());
663            commitment.update(value);
664        }
665        None => commitment.update([0]),
666    }
667}
668
669fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
670    match value {
671        Some(value) => {
672            commitment.update([1]);
673            commitment.update(value.to_be_bytes());
674        }
675        None => commitment.update([0]),
676    }
677}
678
679/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
680/// point for reactive event continuity.
681#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
682pub struct ReactiveCanonicalBaseline {
683    /// Chain whose state the cache snapshot contains.
684    pub chain_id: u64,
685    /// Canonical block through which the snapshot already embodies state.
686    pub block: BlockRef,
687}
688
689impl ReactiveCanonicalBaseline {
690    /// Construct an exact cache snapshot baseline.
691    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
692        Self { chain_id, block }
693    }
694}
695
696/// Ordered chain-lifecycle control delivered by an event subscriber.
697///
698/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
699/// token, durable checkpoint, and ordering guarantees as ordinary event data.
700/// Reorg controls are applied in declaration order before replacement records;
701/// progress, barrier, safe, and finalized controls are committed in declaration
702/// order after the records. A reorg declared after a post-record control is
703/// rejected because its ordering would otherwise be ambiguous.
704#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
705#[non_exhaustive]
706pub enum ChainControl {
707    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
708    Reorg {
709        /// Last block common to the old and new canonical branches.
710        common_ancestor: BlockRef,
711        /// Tip of the branch that ceased to be canonical.
712        old_tip: BlockRef,
713        /// Tip of the newly canonical branch known by the source.
714        new_tip: BlockRef,
715    },
716    /// Update the source's safe head.
717    Safe(BlockRef),
718    /// Update the source's finalized head.
719    Finalized(BlockRef),
720    /// Advance authoritative canonical coverage without fabricating a full header.
721    ///
722    /// Indexers that only know compact block identity should emit this control.
723    /// It never runs block handlers. The runtime exact-hash pins provider reads
724    /// and installs known `NUMBER`/timestamp values, but clears unproven
725    /// header-only environment fields such as base fee and beneficiary.
726    CanonicalProgress(BlockRef),
727    /// Ordered cutover or synchronization fence.
728    Barrier {
729        /// Subscriber-defined opaque barrier identity.
730        id: Vec<u8>,
731        /// Highest canonical event block included before the fence, if known.
732        block: Option<BlockRef>,
733    },
734}
735
736/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
737///
738/// Composite subscribers can persist this small chain-state view beside their
739/// own delivery checkpoint and validate a complete delivery envelope before it
740/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
741/// without matching events need not be present), but it must contain at most
742/// one compatible identity per height. Its oldest entry is also the durable
743/// rollback horizon: an unretained explicit ancestor is accepted only when that
744/// oldest entry is at or below the ancestor. This type carries no cache data,
745/// event payloads, handler state, or transport-specific cursor.
746///
747/// The serde representation is a convenience for caller-owned persistence; it
748/// is not a versioned wire or checkpoint format. Durable protocols should wrap
749/// it in their own versioned envelope and define migrations before upgrading
750/// this pre-1.0 crate. External callers also own retention: successful
751/// validation appends canonical identities but does not silently discard the
752/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
753/// committing the matching source cursor/ACK.
754#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
755pub struct CanonicalSequenceState {
756    retained_canonical_history: Vec<BlockRef>,
757    coverage_head: Option<BlockRef>,
758    safe_head: Option<BlockRef>,
759    finalized_head: Option<BlockRef>,
760}
761
762impl CanonicalSequenceState {
763    /// Construct a validation snapshot from retained canonical metadata.
764    ///
765    /// Construction does not validate ordering, adjacency, coverage, or
766    /// finality invariants. Call [`Self::validate`] before installing decoded or
767    /// externally assembled state.
768    pub fn new(
769        retained_canonical_history: Vec<BlockRef>,
770        coverage_head: Option<BlockRef>,
771        safe_head: Option<BlockRef>,
772        finalized_head: Option<BlockRef>,
773    ) -> Self {
774        Self {
775            retained_canonical_history,
776            coverage_head,
777            safe_head,
778            finalized_head,
779        }
780    }
781
782    /// Sparse retained canonical history in ascending processing order.
783    pub fn retained_canonical_history(&self) -> &[BlockRef] {
784        &self.retained_canonical_history
785    }
786
787    /// Highest canonical identity covered by this state, when known.
788    pub const fn coverage_head(&self) -> Option<&BlockRef> {
789        self.coverage_head.as_ref()
790    }
791
792    /// Latest safe head accepted by the validator, when known.
793    pub const fn safe_head(&self) -> Option<&BlockRef> {
794        self.safe_head.as_ref()
795    }
796
797    /// Latest finalized head accepted by the validator, when known.
798    pub const fn finalized_head(&self) -> Option<&BlockRef> {
799        self.finalized_head.as_ref()
800    }
801
802    /// Retain at most the newest `max_entries` canonical history identities.
803    ///
804    /// Coverage and safe/finalized heads are unchanged. The oldest retained
805    /// identity defines how far strict validation can prove a complete
806    /// rollback, so choose a bound at least as large as the deployment's
807    /// supported reorg depth and trim only after atomically committing the
808    /// corresponding validated state and source cursor. `0` intentionally
809    /// produces a coverage-only snapshot.
810    pub fn retain_recent_history(&mut self, max_entries: usize) {
811        let remove = self
812            .retained_canonical_history
813            .len()
814            .saturating_sub(max_entries);
815        self.retained_canonical_history.drain(..remove);
816    }
817
818    /// Validate a decoded/checkpointed snapshot before installing it.
819    ///
820    /// This rejects out-of-order or conflicting retained identities,
821    /// broken adjacent parent links, retained history without coverage,
822    /// incompatible coverage/finality aliases, hash reuse across heights,
823    /// known parent hashes at non-adjacent heights, finality beyond coverage,
824    /// and a finalized head beyond or conflicting with the safe head.
825    ///
826    /// # Errors
827    ///
828    /// Returns [`ReactiveError`] when any retained identity, parent link,
829    /// coverage alias, or safe/finalized relationship violates the canonical
830    /// snapshot invariants described above.
831    pub fn validate(&self) -> Result<(), ReactiveError> {
832        validate_canonical_sequence_snapshot(self)
833    }
834}
835
836/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
837#[derive(Clone, Debug, PartialEq, Eq)]
838#[non_exhaustive]
839pub enum CanonicalSequenceMutation {
840    /// Rewind the listed retained identities and continue from `common_ancestor`.
841    Rewind {
842        /// Surviving canonical anchor, when one is retained or authenticated.
843        /// `None` is a transient same-envelope state: callers must stage the
844        /// complete validation atomically and may checkpoint only the returned
845        /// `next_state`, after a later canonical mutation installs the proven
846        /// replacement.
847        common_ancestor: Option<BlockRef>,
848        /// Exact retained identities removed by the transition.
849        dropped: Vec<BlockRef>,
850    },
851    /// Accept or enrich one canonical identity.
852    Canonical(BlockRef),
853    /// Accept a safe-head update with metadata resolved against prior state.
854    Safe(BlockRef),
855    /// Accept a finalized-head update with metadata resolved against prior state.
856    Finalized(BlockRef),
857}
858
859/// Successful result of provider-neutral canonical envelope validation.
860#[derive(Clone, Debug, PartialEq, Eq)]
861pub struct CanonicalSequenceValidation {
862    pre_record_state: CanonicalSequenceState,
863    next_state: CanonicalSequenceState,
864    mutations: Vec<CanonicalSequenceMutation>,
865    normalized_chain_controls: Vec<ChainControl>,
866}
867
868impl CanonicalSequenceValidation {
869    /// State after pre-record explicit reorg controls and before event records.
870    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
871        &self.pre_record_state
872    }
873
874    /// Fully validated state after records and post-record controls.
875    pub const fn next_state(&self) -> &CanonicalSequenceState {
876        &self.next_state
877    }
878
879    /// Ordered cache-free canonical mutations proven by this envelope.
880    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
881        &self.mutations
882    }
883
884    /// Controls safe to forward after composite overlap normalization.
885    ///
886    /// Ordinary validation retains the original controls. See
887    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
888    /// compatible stale progress and converts a stale blockful barrier into the
889    /// same barrier identity without a block assertion. Equal-height controls
890    /// that add previously absent parent/timestamp metadata remain present;
891    /// older compatible enrichment is intentionally not applied because the
892    /// corresponding regressive control is not forwarded to the runtime.
893    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
894        &self.normalized_chain_controls
895    }
896}
897
898/// Lifecycle status for an input.
899#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
900#[non_exhaustive]
901pub enum ChainStatus {
902    /// The input is mempool-only and must not mutate canonical cache state.
903    Pending,
904    /// The input is ordered into an ephemeral sequencer-built Flashblock.
905    ///
906    /// Handlers may update the runtime's speculative overlay for this status,
907    /// but the update never advances canonical coverage or durable journals.
908    Preconfirmed {
909        /// Shared exact cumulative pre-confirmation snapshot observed by the
910        /// source. Sharing keeps ordinary canonical records compact and makes
911        /// multi-log Flashblock delivery cheap to clone.
912        flashblock: Arc<FlashblockRef>,
913    },
914    /// The input is included in a block with a confirmation count.
915    Included {
916        /// Included block.
917        block: BlockRef,
918        /// Confirmation count.
919        confirmations: u64,
920    },
921    /// The input is in the chain's safe head.
922    Safe {
923        /// Safe block.
924        block: BlockRef,
925    },
926    /// The input is in the finalized head.
927    Finalized {
928        /// Finalized block.
929        block: BlockRef,
930    },
931    /// The input was dropped by a reorg.
932    Reorged {
933        /// Block the input was dropped from.
934        dropped_from: BlockRef,
935    },
936}
937
938/// Source of an input batch.
939#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
940#[non_exhaustive]
941pub enum InputSource {
942    /// Caller-supplied batch.
943    Batch,
944    /// Live subscription stream.
945    Subscription,
946    /// Polling subscriber.
947    Poll,
948    /// Historical backfill.
949    Backfill,
950    /// Sequencer pre-confirmation / Flashblocks surface.
951    Flashblocks,
952    /// Test or synthetic input.
953    Synthetic,
954}
955
956/// Stable identity used for input deduplication and reports.
957#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
958pub enum InputRef {
959    /// Stable log identity.
960    Log {
961        /// Chain id, when known.
962        chain_id: Option<u64>,
963        /// Block hash containing the log.
964        block_hash: B256,
965        /// Transaction hash that emitted the log.
966        transaction_hash: B256,
967        /// Log index within the block.
968        log_index: u64,
969    },
970    /// Stable pending transaction identity.
971    PendingTx {
972        /// Chain id, when known.
973        chain_id: Option<u64>,
974        /// Transaction hash.
975        hash: B256,
976    },
977    /// Stable block identity.
978    Block {
979        /// Chain id, when known.
980        chain_id: Option<u64>,
981        /// Block hash.
982        hash: B256,
983        /// Block number.
984        number: u64,
985    },
986}
987
988/// Representation and lifecycle class retained alongside an [`InputRef`].
989///
990/// `InputRef` identifies the underlying chain object. This discriminator keeps
991/// distinct handler inputs from collapsing merely because they commit to the
992/// same object: a header and full block, a pending hash and hydrated body, and
993/// canonical versus reorg-signalling log delivery are independently routable.
994#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
995#[non_exhaustive]
996pub enum ReactiveInputKind {
997    /// Canonical log data.
998    CanonicalLog,
999    /// Removed or otherwise reorg-signalling log data.
1000    ReorgSignalLog,
1001    /// Header-only block representation.
1002    BlockHeader,
1003    /// Full block representation.
1004    FullBlock,
1005    /// Hash-only pending transaction representation.
1006    PendingTxHash,
1007    /// Hydrated pending transaction representation.
1008    PendingTx,
1009}
1010
1011/// Validated, representation-aware identity for one reactive input.
1012///
1013/// Composite subscribers can use this as a dedupe key without conflating
1014/// independently routable representations. When a key repeats, use
1015/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
1016/// provider overlap from a conflicting payload.
1017#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1018pub struct ReactiveInputIdentity {
1019    input_ref: InputRef,
1020    kind: ReactiveInputKind,
1021}
1022
1023impl ReactiveInputIdentity {
1024    /// Validate and construct an identity from explicit wire/codec parts.
1025    ///
1026    /// `InputRef` identifies the underlying object, while `kind` identifies its
1027    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
1028    /// block representations with [`InputRef::Block`], and pending-transaction
1029    /// representations with [`InputRef::PendingTx`]. This constructor lets
1030    /// external codecs rebuild the otherwise-private invariant without serde or
1031    /// layout-dependent decoding.
1032    ///
1033    /// # Errors
1034    ///
1035    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
1036    /// to the supplied representation `kind`.
1037    pub fn try_from_parts(
1038        input_ref: InputRef,
1039        kind: ReactiveInputKind,
1040    ) -> Result<Self, ReactiveInputIdentityError> {
1041        let compatible = matches!(
1042            (input_ref, kind),
1043            (
1044                InputRef::Log { .. },
1045                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
1046            ) | (
1047                InputRef::Block { .. },
1048                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
1049            ) | (
1050                InputRef::PendingTx { .. },
1051                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
1052            )
1053        );
1054        if !compatible {
1055            return Err(ReactiveInputIdentityError { input_ref, kind });
1056        }
1057        Ok(Self { input_ref, kind })
1058    }
1059
1060    /// Underlying stable chain-object reference.
1061    pub const fn input_ref(&self) -> InputRef {
1062        self.input_ref
1063    }
1064
1065    /// Exact handler-input representation and lifecycle class.
1066    pub const fn kind(&self) -> ReactiveInputKind {
1067        self.kind
1068    }
1069}
1070
1071/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
1072/// object/representation classes.
1073#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1074#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
1075pub struct ReactiveInputIdentityError {
1076    input_ref: InputRef,
1077    kind: ReactiveInputKind,
1078}
1079
1080impl ReactiveInputIdentityError {
1081    /// Rejected stable object reference.
1082    pub const fn input_ref(&self) -> InputRef {
1083        self.input_ref
1084    }
1085
1086    /// Rejected representation/lifecycle kind.
1087    pub const fn kind(&self) -> ReactiveInputKind {
1088        self.kind
1089    }
1090}
1091
1092/// Reliability of state effects emitted by a handler.
1093#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1094pub enum StateEffectQuality {
1095    /// Effects are exact from the input alone.
1096    ExactFromInput,
1097    /// Effects were applied, but follow-up resync is pending.
1098    AppliedWithPendingResync,
1099    /// Effects came from authoritative resync.
1100    ResyncedAuthoritatively,
1101    /// State requires repair before it should be trusted.
1102    RequiresRepair,
1103    /// No canonical state effect was emitted.
1104    NoStateEffect,
1105}
1106
1107/// Identifier for a reactive handler.
1108#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
1109pub struct HandlerId(String);
1110
1111impl HandlerId {
1112    /// Create a non-empty handler id.
1113    ///
1114    /// # Panics
1115    ///
1116    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
1117    /// configuration or wire input.
1118    pub fn new(id: impl Into<String>) -> Self {
1119        Self::try_new(id).expect("handler id must not be empty")
1120    }
1121
1122    /// Validate and create a handler id from untrusted input.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
1127    /// reserved for canonical/global protocol scope.
1128    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
1129        let id = id.into();
1130        if id.is_empty() {
1131            return Err(HandlerIdError);
1132        }
1133        Ok(Self(id))
1134    }
1135
1136    /// Return the id as a string slice.
1137    pub fn as_str(&self) -> &str {
1138        &self.0
1139    }
1140}
1141
1142impl<'de> serde::Deserialize<'de> for HandlerId {
1143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1144    where
1145        D: serde::Deserializer<'de>,
1146    {
1147        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
1148        Self::try_new(id).map_err(serde::de::Error::custom)
1149    }
1150}
1151
1152/// An empty handler identity cannot be represented portably across subscriber
1153/// protocols because the empty owner is reserved for canonical/global scope.
1154#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1155#[error("handler id must not be empty")]
1156pub struct HandlerIdError;
1157
1158impl fmt::Display for HandlerId {
1159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1160        self.0.fmt(f)
1161    }
1162}
1163
1164/// Lightweight report label.
1165#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1166pub struct ReportTag {
1167    /// Label key.
1168    pub key: String,
1169    /// Label value.
1170    pub value: String,
1171}
1172
1173impl ReportTag {
1174    /// Create a report tag.
1175    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1176        Self {
1177            key: key.into(),
1178            value: value.into(),
1179        }
1180    }
1181}
1182
1183/// Domain-neutral hook signal emitted by a handler.
1184#[derive(Clone)]
1185pub struct HookSignal {
1186    /// Signal namespace owned by the caller.
1187    pub namespace: Cow<'static, str>,
1188    /// Signal kind within the namespace.
1189    pub kind: Cow<'static, str>,
1190    /// Additional labels for routing or observability.
1191    pub labels: Vec<ReportTag>,
1192    /// Optional in-process typed payload.
1193    pub payload: Option<Arc<dyn Any + Send + Sync>>,
1194}
1195
1196impl fmt::Debug for HookSignal {
1197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1198        f.debug_struct("HookSignal")
1199            .field("namespace", &self.namespace)
1200            .field("kind", &self.kind)
1201            .field("labels", &self.labels)
1202            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
1203            .finish()
1204    }
1205}
1206
1207/// Effect emitted by a [`ReactiveHandler`].
1208#[derive(Clone, Debug)]
1209pub enum ReactiveEffect {
1210    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
1211    StateUpdate(StateUpdate),
1212    /// Request for authoritative state repair.
1213    Resync(ResyncRequest),
1214    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
1215    Invalidate(InvalidationRequest),
1216    /// Hook signal dispatched after committed mutation phases.
1217    Hook(HookSignal),
1218    /// Speculative signal for mempool or downstream work.
1219    Speculative(SpeculativeRequest),
1220}
1221
1222/// Handler output for a single input.
1223#[derive(Clone, Debug)]
1224pub struct HandlerOutcome {
1225    /// Effects emitted by the handler.
1226    pub effects: Vec<ReactiveEffect>,
1227    /// Reliability of emitted state effects.
1228    pub quality: StateEffectQuality,
1229    /// Labels copied into reports.
1230    pub tags: Vec<ReportTag>,
1231}
1232
1233impl HandlerOutcome {
1234    /// Construct an empty outcome with the supplied quality.
1235    pub fn empty(quality: StateEffectQuality) -> Self {
1236        Self {
1237            effects: Vec::new(),
1238            quality,
1239            tags: Vec::new(),
1240        }
1241    }
1242}
1243
1244/// One input and its execution context.
1245#[derive(Clone, Debug)]
1246pub struct ReactiveInputRecord<N: Network = Ethereum> {
1247    /// Input value.
1248    pub input: ReactiveInput<N>,
1249    /// Input context.
1250    pub context: ReactiveContext,
1251    /// Provider session that originated this input, when it came from a
1252    /// concrete provider rather than a synthetic or aggregate source.
1253    pub provider: Option<ProviderRef>,
1254}
1255
1256impl<N: Network> ReactiveInputRecord<N> {
1257    /// Create an input record.
1258    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
1259        Self {
1260            input,
1261            context,
1262            provider: None,
1263        }
1264    }
1265
1266    /// Attach provider provenance used to route follow-up reads.
1267    #[must_use]
1268    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
1269        self.provider = Some(provider);
1270        self
1271    }
1272
1273    /// Compute the stable input reference used for deduplication.
1274    pub fn input_ref(&self) -> InputRef {
1275        input_ref(&self.input, &self.context)
1276    }
1277
1278    /// Validate payload/context coherence and return a representation-aware
1279    /// identity suitable for subscriber and runtime deduplication.
1280    ///
1281    /// Validation is fail-closed for canonical logs: their block, transaction,
1282    /// and log positions must be complete and agree with the context. Block and
1283    /// pending-transaction representations receive the corresponding lifecycle,
1284    /// inclusion-wrapper, and payload/context checks. This does not recompute a
1285    /// claimed header hash, transaction root, or transaction signature; exact
1286    /// subscriber payload commitments remain the transport-integrity boundary
1287    /// for those cryptographic claims.
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
1292    /// lifecycle, inclusion metadata, or context is incomplete or internally
1293    /// inconsistent.
1294    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
1295        validate_input_record(self)?;
1296        let kind = match &self.input {
1297            ReactiveInput::Log(log)
1298                if log.removed
1299                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
1300            {
1301                ReactiveInputKind::ReorgSignalLog
1302            }
1303            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
1304            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
1305            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
1306            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
1307            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
1308        };
1309        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
1310            ReactiveError::InvalidInputRecord {
1311                message: error.to_string(),
1312            }
1313        })
1314    }
1315
1316    /// Whether two same-identity records carry the same deduplicable payload.
1317    ///
1318    /// This deliberately ignores [`ReactiveContext`]: the same provider object
1319    /// can legitimately arrive from backfill and subscription transports with
1320    /// different provenance or confirmation metadata. Callers must first
1321    /// compare [`validated_identity`](Self::validated_identity) and reconcile
1322    /// lifecycle/context authority separately. Logs are compared structurally;
1323    /// block and transaction hashes are cryptographic commitments for the
1324    /// remaining same-representation payloads. Full block responses and
1325    /// hydrated pending transaction bodies deliberately return `false`: the
1326    /// core does not currently prove a supplied body against the header's
1327    /// transaction root or compare every response field, so a composite source
1328    /// must preserve both rather than suppress one based only on its hash.
1329    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
1330        match (&self.input, &other.input) {
1331            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
1332                left.inner == right.inner
1333                    && left.block_hash == right.block_hash
1334                    && left.block_number == right.block_number
1335                    && optional_metadata_compatible(
1336                        left.block_timestamp.as_ref(),
1337                        right.block_timestamp.as_ref(),
1338                    )
1339                    && left.transaction_hash == right.transaction_hash
1340                    && left.transaction_index == right.transaction_index
1341                    && left.log_index == right.log_index
1342                    && left.removed == right.removed
1343            }
1344            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
1345                left.hash() == right.hash()
1346            }
1347            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
1348            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
1349                left == right
1350            }
1351            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
1352            _ => false,
1353        }
1354    }
1355
1356    /// Whether this representation has a complete payload-equivalence contract
1357    /// and may participate in duplicate suppression.
1358    ///
1359    /// Full block and hydrated pending transaction bodies are intentionally
1360    /// excluded until their complete body/response integrity is validated.
1361    pub fn is_payload_deduplicable(&self) -> bool {
1362        matches!(
1363            &self.input,
1364            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
1365        )
1366    }
1367
1368    /// Merge `other` when it is the same safely deduplicable provider object.
1369    ///
1370    /// Returns `Ok(false)` for a different identity or a representation whose
1371    /// complete payload cannot be proven equivalent. A same-identity payload or
1372    /// semantic conflict returns an error. Successful merges are deterministic:
1373    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
1374    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
1375    /// and provenance uses a stable source priority. The result is therefore
1376    /// independent of historical/live arrival order.
1377    ///
1378    /// # Errors
1379    ///
1380    /// Returns [`ReactiveError`] when either record is invalid, or when equal
1381    /// identities carry conflicting payload or semantic context.
1382    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
1383        let identity = self.validated_identity()?;
1384        let other_identity = other.validated_identity()?;
1385        if identity != other_identity
1386            || !self.is_payload_deduplicable()
1387            || !other.is_payload_deduplicable()
1388        {
1389            return Ok(false);
1390        }
1391        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
1392            return Err(ReactiveError::InvalidInputRecord {
1393                message: format!(
1394                    "conflicting payload or semantic context for identity {identity:?}"
1395                ),
1396            });
1397        }
1398        let mut merged = self.clone();
1399        merge_deduplicable_record(&mut merged, other);
1400        merged.validated_identity()?;
1401        *self = merged;
1402        Ok(true)
1403    }
1404
1405    /// Whether semantic context agrees for deduplication across transports.
1406    ///
1407    /// Provenance source and confirmation count may legitimately differ at a
1408    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1409    /// transaction/log positions must agree. Block number/hash are exact;
1410    /// optional parent/timestamp metadata may be enriched by one source but two
1411    /// present conflicting values are rejected.
1412    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1413        let left = &self.context;
1414        let right = &other.context;
1415        left.chain_id == right.chain_id
1416            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1417            && left.transaction_index == right.transaction_index
1418            && left.log_index == right.log_index
1419            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1420    }
1421}
1422
1423fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1424    match (left, right) {
1425        (ChainStatus::Pending, ChainStatus::Pending)
1426        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1427        (
1428            ChainStatus::Preconfirmed { flashblock: left },
1429            ChainStatus::Preconfirmed { flashblock: right },
1430        ) => left == right,
1431        (
1432            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1433            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1434        ) => true,
1435        _ => false,
1436    }
1437}
1438
1439fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1440    left.zip(right).is_none_or(|(left, right)| left == right)
1441}
1442
1443fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1444    match (left, right) {
1445        (None, None) => true,
1446        (Some(left), Some(right)) => {
1447            left.number == right.number
1448                && left.hash == right.hash
1449                && optional_metadata_compatible(
1450                    left.parent_hash.as_ref(),
1451                    right.parent_hash.as_ref(),
1452                )
1453                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1454        }
1455        _ => false,
1456    }
1457}
1458
1459fn merge_deduplicable_record<N: Network>(
1460    retained: &mut ReactiveInputRecord<N>,
1461    incoming: &ReactiveInputRecord<N>,
1462) {
1463    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1464        (&mut retained.input, &incoming.input)
1465        && retained.block_timestamp.is_none()
1466    {
1467        retained.block_timestamp = incoming.block_timestamp;
1468    }
1469    if let (Some(retained), Some(incoming)) =
1470        (&mut retained.context.block, incoming.context.block.as_ref())
1471    {
1472        enrich_block_ref(retained, incoming);
1473    }
1474    retained.context.chain_status = merged_chain_status(
1475        &retained.context.chain_status,
1476        &incoming.context.chain_status,
1477    );
1478    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1479        retained.context.source = incoming.context.source;
1480    }
1481    if retained.provider.is_none() {
1482        retained.provider = incoming.provider.clone();
1483    }
1484}
1485
1486fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1487    if retained.parent_hash.is_none() {
1488        retained.parent_hash = incoming.parent_hash;
1489    }
1490    if retained.timestamp.is_none() {
1491        retained.timestamp = incoming.timestamp;
1492    }
1493}
1494
1495fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1496    let merged_block = |left: &BlockRef, right: &BlockRef| {
1497        let mut block = *left;
1498        enrich_block_ref(&mut block, right);
1499        block
1500    };
1501    match (retained, incoming) {
1502        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1503        (
1504            ChainStatus::Preconfirmed { flashblock: left },
1505            ChainStatus::Preconfirmed { flashblock: right },
1506        ) => {
1507            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1508            ChainStatus::Preconfirmed {
1509                flashblock: left.clone(),
1510            }
1511        }
1512        (
1513            ChainStatus::Reorged { dropped_from: left },
1514            ChainStatus::Reorged {
1515                dropped_from: right,
1516            },
1517        ) => ChainStatus::Reorged {
1518            dropped_from: merged_block(left, right),
1519        },
1520        (left, right) => {
1521            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1522                .expect("compatible duplicate has a canonical lifecycle");
1523            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1524                .expect("compatible duplicate has a canonical lifecycle");
1525            let block = merged_block(left_block, right_block);
1526            let rank = left_rank.max(right_rank);
1527            match rank {
1528                3 => ChainStatus::Finalized { block },
1529                2 => ChainStatus::Safe { block },
1530                _ => ChainStatus::Included {
1531                    block,
1532                    confirmations: left_confirmations.max(right_confirmations),
1533                },
1534            }
1535        }
1536    }
1537}
1538
1539fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1540    match status {
1541        ChainStatus::Included {
1542            block,
1543            confirmations,
1544        } => Some((block, 1, *confirmations)),
1545        ChainStatus::Safe { block } => Some((block, 2, 0)),
1546        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1547        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1548            None
1549        }
1550    }
1551}
1552
1553fn input_source_rank(source: InputSource) -> u8 {
1554    match source {
1555        InputSource::Backfill => 0,
1556        InputSource::Poll => 1,
1557        InputSource::Subscription => 2,
1558        InputSource::Flashblocks => 3,
1559        InputSource::Batch => 4,
1560        InputSource::Synthetic => 5,
1561    }
1562}
1563
1564/// Opaque subscriber-owned token attached to a delivered input batch.
1565///
1566/// Subscribers that provide durable, at-least-once delivery can use this token
1567/// to identify the batch that becomes committable after runtime ingestion
1568/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1569/// stable across replay, and must never identify two different batch payloads.
1570/// Subscriber implementations must preserve delivery order while one token is
1571/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1572/// later batch.
1573#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1574pub struct SubscriberDeliveryToken(Vec<u8>);
1575
1576impl SubscriberDeliveryToken {
1577    /// Create an opaque delivery token from subscriber-owned bytes.
1578    pub fn new(bytes: Vec<u8>) -> Self {
1579        Self(bytes)
1580    }
1581
1582    /// Borrow the opaque token bytes.
1583    pub fn as_bytes(&self) -> &[u8] {
1584        &self.0
1585    }
1586
1587    /// Consume the token into its opaque bytes.
1588    pub fn into_bytes(self) -> Vec<u8> {
1589        self.0
1590    }
1591}
1592
1593/// Opaque source checkpoint associated with a delivered batch.
1594///
1595/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1596/// acknowledge, this value describes provider-specific resume state. The core
1597/// crate persists and returns the bytes without interpreting their format.
1598#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1599pub struct SubscriberCheckpoint(Vec<u8>);
1600
1601impl SubscriberCheckpoint {
1602    /// Create an opaque source checkpoint from subscriber-owned bytes.
1603    pub fn new(bytes: Vec<u8>) -> Self {
1604        Self(bytes)
1605    }
1606
1607    /// Borrow the opaque checkpoint bytes.
1608    pub fn as_bytes(&self) -> &[u8] {
1609        &self.0
1610    }
1611
1612    /// Consume the checkpoint into its opaque bytes.
1613    pub fn into_bytes(self) -> Vec<u8> {
1614        self.0
1615    }
1616}
1617
1618/// Subscriber-supplied commitment to the exact canonical wire payload of one
1619/// delivered batch.
1620///
1621/// The core includes this value in its durable replay witness. It is required
1622/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1623/// network-generic Rust response types cannot be serialized completely by the
1624/// core. The source must recompute the commitment from a stable canonical
1625/// encoding on every replay; reusing a commitment for changed bytes violates the
1626/// [`EventSubscriber`] contract.
1627#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1628pub struct SubscriberPayloadCommitment(B256);
1629
1630impl SubscriberPayloadCommitment {
1631    /// Wrap a cryptographic commitment produced by the subscriber.
1632    pub const fn new(commitment: B256) -> Self {
1633        Self(commitment)
1634    }
1635
1636    /// Return the committed digest.
1637    pub const fn digest(&self) -> B256 {
1638        self.0
1639    }
1640}
1641
1642/// Durable subscriber position restored together with cache/runtime state.
1643///
1644/// The core never interprets provider checkpoint bytes. Composite and remote
1645/// subscribers use this synchronous hand-off to seed their source cursors,
1646/// replay fences, and canonical overlap journals before polling resumes.
1647#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1648#[non_exhaustive]
1649pub struct SubscriberResumePosition {
1650    /// Chain whose canonical position and provider cursor are being restored.
1651    pub chain_id: u64,
1652    /// Authoritative canonical coverage embodied by the restored cache.
1653    pub coverage_head: BlockRef,
1654    /// Ordered canonical identities still retained for in-window reconciliation.
1655    pub canonical_history: Vec<BlockRef>,
1656    /// Last delivery token whose effects are already represented by the cache.
1657    /// It may still be pending at the source when the process stopped after its
1658    /// durable save but before the source acknowledgement committed.
1659    pub delivery_token: Option<SubscriberDeliveryToken>,
1660    /// Provider-specific durable cursor committed with that delivery.
1661    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1662}
1663
1664impl SubscriberResumePosition {
1665    /// Construct a complete restored subscriber position.
1666    pub fn new(
1667        chain_id: u64,
1668        coverage_head: BlockRef,
1669        canonical_history: Vec<BlockRef>,
1670        delivery_token: Option<SubscriberDeliveryToken>,
1671        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1672    ) -> Self {
1673        Self {
1674            chain_id,
1675            coverage_head,
1676            canonical_history,
1677            delivery_token,
1678            subscriber_checkpoint,
1679        }
1680    }
1681}
1682
1683/// Runtime routing audience for one delivered subscriber batch.
1684///
1685/// Historical catch-up for a newly registered handler must not be routed
1686/// through older handlers whose filters happen to overlap. Subscribers retain
1687/// that provenance by targeting the batch at the exact logical owners that
1688/// requested it. Ordinary canonical delivery remains broadcast to every
1689/// matching handler.
1690#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1691#[non_exhaustive]
1692pub enum DeliveryAudience {
1693    /// Route each record through every matching registered handler.
1694    #[default]
1695    All,
1696    /// Route each record only through the named matching handlers.
1697    Owners(Vec<HandlerId>),
1698    /// Route through every matching handler except the named owners.
1699    ///
1700    /// Composite subscribers use this to deliver the residual audience after an
1701    /// overlapping source already committed the same input for selected owners.
1702    AllExcept(Vec<HandlerId>),
1703}
1704
1705/// How one delivered record participates in the runtime's canonical state machine.
1706///
1707/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1708/// selects handlers, while this value decides whether a record may advance or
1709/// rewind global chain state. Historical replay for a newly added owner must use
1710/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1711/// is canonical.
1712#[derive(
1713    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1714)]
1715#[non_exhaustive]
1716pub enum DeliveryScope {
1717    /// Authoritative live canonical delivery.
1718    #[default]
1719    Canonical,
1720    /// Authoritative historical/recovery delivery that advances canonical progress.
1721    CanonicalProgress,
1722    /// Historical replay routed to selected owners without changing global chain state.
1723    OwnerCatchup,
1724    /// Ephemeral pre-confirmation delivery applied only to the speculative
1725    /// cache overlay.
1726    Preconfirmed,
1727}
1728
1729impl DeliveryScope {
1730    const fn advances_canonical_state(self) -> bool {
1731        matches!(self, Self::Canonical | Self::CanonicalProgress)
1732    }
1733}
1734
1735/// One input together with its routing and canonical-processing provenance.
1736#[derive(Clone, Debug)]
1737pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1738    record: ReactiveInputRecord<N>,
1739    audience: DeliveryAudience,
1740    scope: DeliveryScope,
1741}
1742
1743impl<N: Network> ReactiveInputDelivery<N> {
1744    /// Construct one lossless delivered record.
1745    pub fn new(
1746        record: ReactiveInputRecord<N>,
1747        audience: DeliveryAudience,
1748        scope: DeliveryScope,
1749    ) -> Self {
1750        Self {
1751            record,
1752            audience,
1753            scope,
1754        }
1755    }
1756
1757    /// Borrow the runtime input record.
1758    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1759        &self.record
1760    }
1761
1762    /// Borrow the exact routing audience.
1763    pub const fn audience(&self) -> &DeliveryAudience {
1764        &self.audience
1765    }
1766
1767    /// Return the record's canonical-processing scope.
1768    pub const fn scope(&self) -> DeliveryScope {
1769        self.scope
1770    }
1771
1772    /// Consume this value into its complete parts.
1773    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1774        (self.record, self.audience, self.scope)
1775    }
1776}
1777
1778/// Complete contents of a consumed [`ReactiveInputBatch`].
1779///
1780/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1781/// discards subscriber commit and chain-lifecycle metadata.
1782#[derive(Clone, Debug)]
1783#[non_exhaustive]
1784pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1785    /// Authoritative chain identity for controls and records in this batch.
1786    pub chain_id: Option<u64>,
1787    /// Records with per-record routing and chain provenance.
1788    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1789    /// Subscriber delivery token committed after ingestion.
1790    pub delivery_token: Option<SubscriberDeliveryToken>,
1791    /// Provider-specific resume cursor associated with the delivery.
1792    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1793    /// Exact opaque wire-payload commitment supplied by the subscriber.
1794    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1795    /// Ordered chain controls sharing the delivery's commit boundary.
1796    pub chain_controls: Vec<ChainControl>,
1797    /// Original typed source ingress for a preconfirmed-only batch.
1798    pub preconfirmation_timing: Option<FlashblockIngressTiming>,
1799}
1800
1801/// Batch of reactive input records.
1802#[derive(Clone, Debug)]
1803pub struct ReactiveInputBatch<N: Network = Ethereum> {
1804    records: Vec<ReactiveInputRecord<N>>,
1805    chain_id: Option<u64>,
1806    delivery_token: Option<SubscriberDeliveryToken>,
1807    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1808    payload_commitment: Option<SubscriberPayloadCommitment>,
1809    audience: DeliveryAudience,
1810    record_audiences: Option<Vec<DeliveryAudience>>,
1811    delivery_scope: DeliveryScope,
1812    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1813    chain_controls: Vec<ChainControl>,
1814    preconfirmation_timing: Option<FlashblockIngressTiming>,
1815}
1816
1817type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1818
1819impl<N: Network> ReactiveInputBatch<N> {
1820    /// Create a batch from records.
1821    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1822        let chain_id = common_record_chain_id(&records);
1823        Self {
1824            records,
1825            chain_id,
1826            delivery_token: None,
1827            subscriber_checkpoint: None,
1828            payload_commitment: None,
1829            audience: DeliveryAudience::All,
1830            record_audiences: None,
1831            delivery_scope: DeliveryScope::Canonical,
1832            record_delivery_scopes: None,
1833            chain_controls: Vec::new(),
1834            preconfirmation_timing: None,
1835        }
1836    }
1837
1838    /// Bind the complete batch, including control-only progress/finality, to a
1839    /// chain. Runtime ingestion rejects a different cache chain.
1840    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1841        self.chain_id = Some(chain_id);
1842        self
1843    }
1844
1845    /// Authoritative batch chain identity, when supplied or unambiguously
1846    /// derived from its records.
1847    pub const fn chain_id(&self) -> Option<u64> {
1848        self.chain_id
1849    }
1850
1851    /// Attach the subscriber-owned token committed after successful ingestion.
1852    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1853        self.delivery_token = Some(token);
1854        self
1855    }
1856
1857    /// Borrow the subscriber-owned delivery token, when present.
1858    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1859        self.delivery_token.as_ref()
1860    }
1861
1862    /// Attach provider-specific resume state included by this delivery.
1863    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1864        self.subscriber_checkpoint = Some(checkpoint);
1865        self
1866    }
1867
1868    /// Borrow provider-specific resume state, when present.
1869    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1870        self.subscriber_checkpoint.as_ref()
1871    }
1872
1873    /// Attach a commitment to the exact canonical wire payload represented by
1874    /// this batch.
1875    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1876        self.payload_commitment = Some(commitment);
1877        self
1878    }
1879
1880    /// Borrow the subscriber-supplied exact payload commitment, when present.
1881    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1882        self.payload_commitment.as_ref()
1883    }
1884
1885    /// Restrict runtime routing to exact logical interest owners.
1886    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1887        self.audience = audience;
1888        self.record_audiences = None;
1889        self
1890    }
1891
1892    /// Delivery audience captured by the subscriber.
1893    pub const fn audience(&self) -> &DeliveryAudience {
1894        &self.audience
1895    }
1896
1897    /// Create a batch whose records retain independent delivery audiences.
1898    pub fn from_scoped_records(
1899        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1900    ) -> Self {
1901        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1902        let chain_id = common_record_chain_id(&records);
1903        Self {
1904            records,
1905            chain_id,
1906            delivery_token: None,
1907            subscriber_checkpoint: None,
1908            payload_commitment: None,
1909            audience: DeliveryAudience::All,
1910            record_audiences: Some(record_audiences),
1911            delivery_scope: DeliveryScope::Canonical,
1912            record_delivery_scopes: None,
1913            chain_controls: Vec::new(),
1914            preconfirmation_timing: None,
1915        }
1916    }
1917
1918    /// Create a batch with independent routing and canonical provenance per record.
1919    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1920        Self::from_scoped_records_with_delivery_scope(
1921            deliveries
1922                .into_iter()
1923                .map(ReactiveInputDelivery::into_parts),
1924        )
1925    }
1926
1927    /// Audience for the record at `index`.
1928    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1929        if index >= self.records.len() {
1930            return None;
1931        }
1932        Some(
1933            self.record_audiences
1934                .as_ref()
1935                .and_then(|audiences| audiences.get(index))
1936                .unwrap_or(&self.audience),
1937        )
1938    }
1939
1940    /// Set how every record in this batch participates in canonical state.
1941    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1942        self.delivery_scope = scope;
1943        self.record_delivery_scopes = None;
1944        self
1945    }
1946
1947    /// Canonical-processing scope for the record at `index`.
1948    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
1949        if index >= self.records.len() {
1950            return None;
1951        }
1952        Some(
1953            self.record_delivery_scopes
1954                .as_ref()
1955                .and_then(|scopes| scopes.get(index))
1956                .copied()
1957                .unwrap_or(self.delivery_scope),
1958        )
1959    }
1960
1961    fn from_scoped_records_with_delivery_scope(
1962        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
1963    ) -> Self {
1964        let mut input_records = Vec::new();
1965        let mut audiences = Vec::new();
1966        let mut scopes = Vec::new();
1967        for (record, audience, scope) in records {
1968            input_records.push(record);
1969            audiences.push(audience);
1970            scopes.push(scope);
1971        }
1972        let chain_id = common_record_chain_id(&input_records);
1973        Self {
1974            records: input_records,
1975            chain_id,
1976            delivery_token: None,
1977            subscriber_checkpoint: None,
1978            payload_commitment: None,
1979            audience: DeliveryAudience::All,
1980            record_audiences: Some(audiences),
1981            delivery_scope: DeliveryScope::Canonical,
1982            record_delivery_scopes: Some(scopes),
1983            chain_controls: Vec::new(),
1984            preconfirmation_timing: None,
1985        }
1986    }
1987
1988    /// Attach original typed source ingress to a preconfirmed-only batch.
1989    pub fn with_preconfirmation_timing(mut self, timing: FlashblockIngressTiming) -> Self {
1990        self.preconfirmation_timing = Some(timing);
1991        self
1992    }
1993
1994    /// Original typed source ingress for a preconfirmed-only batch.
1995    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
1996        self.preconfirmation_timing
1997    }
1998
1999    /// Attach ordered chain-lifecycle controls to this delivery.
2000    ///
2001    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
2002    /// When records are present, their unanimous chain id is derived by the
2003    /// constructor; a missing or cache-mismatched authoritative batch identity
2004    /// is rejected before any control mutates runtime state.
2005    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
2006        self.chain_controls = controls.into_iter().collect();
2007        self
2008    }
2009
2010    /// Ordered chain-lifecycle controls in this delivery.
2011    pub fn chain_controls(&self) -> &[ChainControl] {
2012        &self.chain_controls
2013    }
2014
2015    /// Borrow the records in this batch.
2016    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
2017        &self.records
2018    }
2019
2020    /// Consume the batch into only its input records.
2021    ///
2022    /// This is intentionally lossy: it discards the authoritative batch chain
2023    /// identity, routing audiences, delivery scopes, ordered chain controls,
2024    /// acknowledgement tokens, and provider checkpoints. Adapters should use
2025    /// [`into_parts`](Self::into_parts) instead.
2026    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
2027        self.records
2028    }
2029
2030    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
2031    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
2032        let chain_id = self.chain_id;
2033        let delivery_token = self.delivery_token;
2034        let subscriber_checkpoint = self.subscriber_checkpoint;
2035        let payload_commitment = self.payload_commitment;
2036        let chain_controls = self.chain_controls;
2037        let preconfirmation_timing = self.preconfirmation_timing;
2038        let audiences = self
2039            .record_audiences
2040            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2041        let scopes = self
2042            .record_delivery_scopes
2043            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2044        let deliveries = self
2045            .records
2046            .into_iter()
2047            .zip(audiences)
2048            .zip(scopes)
2049            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
2050            .collect();
2051        ReactiveInputBatchParts {
2052            chain_id,
2053            deliveries,
2054            delivery_token,
2055            subscriber_checkpoint,
2056            payload_commitment,
2057            chain_controls,
2058            preconfirmation_timing,
2059        }
2060    }
2061
2062    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
2063        let audiences = self
2064            .record_audiences
2065            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2066        let scopes = self
2067            .record_delivery_scopes
2068            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2069        let records = self
2070            .records
2071            .into_iter()
2072            .zip(audiences)
2073            .zip(scopes)
2074            .map(|((record, audience), scope)| (record, audience, scope))
2075            .collect();
2076        (records, self.chain_controls, self.chain_id)
2077    }
2078
2079    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
2080        self.delivery_token.take()
2081    }
2082
2083    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
2084        self.subscriber_checkpoint.take()
2085    }
2086}
2087
2088fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
2089    let chain_id = records.first()?.context.chain_id?;
2090    records
2091        .iter()
2092        .all(|record| record.context.chain_id == Some(chain_id))
2093        .then_some(chain_id)
2094}
2095
2096/// Pure synchronous handler for reactive inputs.
2097pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
2098    /// Stable handler id.
2099    fn id(&self) -> HandlerId;
2100
2101    /// Interests used by subscribers and the local router.
2102    fn interests(&self) -> Vec<ReactiveInterest<N>>;
2103
2104    /// Exhaustive exact keys for log inputs this handler can accept.
2105    ///
2106    /// Returning `None` keeps the handler on the compatibility fallback path.
2107    /// Returning an index promises that every matching log has at least one of
2108    /// its keys; the registry still re-checks the handler's original
2109    /// [`LogInterest`]s and local matchers before dispatch.
2110    fn log_route_index(&self) -> Option<LogRouteIndex> {
2111        None
2112    }
2113
2114    /// Handle one input against a read-only cache view.
2115    fn handle(
2116        &self,
2117        ctx: &ReactiveContext,
2118        input: &ReactiveInput<N>,
2119        state: &dyn StateView,
2120    ) -> Result<HandlerOutcome, HandlerError>;
2121}
2122
2123/// Hook invoked after reports are built and cache mutation phases have ended.
2124///
2125/// Hooks are synchronous in-process observers, not a durable transactional
2126/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
2127/// back during checkpoint staging, and it dispatches a successfully staged
2128/// batch at most once per live engine. A process crash can still occur between
2129/// hook dispatch and durable checkpoint or transport acknowledgement. External
2130/// side effects therefore need their own idempotency key (normally an
2131/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
2132pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
2133    /// Observe a runtime report.
2134    fn on_report(&self, report: Arc<ReactiveReport<N>>);
2135}
2136
2137/// Reactive subscription interest.
2138#[allow(clippy::large_enum_variant)]
2139#[derive(Clone)]
2140pub enum ReactiveInterest<N: Network = Ethereum> {
2141    /// Log interest.
2142    Logs(LogInterest),
2143    /// Block interest.
2144    Blocks(BlockInterest),
2145    /// Pending transaction interest.
2146    PendingTransactions(PendingTxInterest<N>),
2147}
2148
2149impl<N: Network> fmt::Debug for ReactiveInterest<N> {
2150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2151        match self {
2152            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
2153            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
2154            Self::PendingTransactions(interest) => f
2155                .debug_tuple("PendingTransactions")
2156                .field(interest)
2157                .finish(),
2158        }
2159    }
2160}
2161
2162/// Interest in logs.
2163#[derive(Clone)]
2164pub struct LogInterest {
2165    /// Provider-side filter.
2166    pub provider_filter: Filter,
2167    /// Optional local matcher for predicates providers cannot express.
2168    pub local_matcher: Option<Arc<dyn LogMatcher>>,
2169    /// Optional route-key extraction strategy.
2170    pub route_key: Option<RouteKeySpec>,
2171}
2172
2173impl LogInterest {
2174    /// Return true if the log matches both the provider filter and local matcher.
2175    pub fn matches(&self, log: &Log) -> bool {
2176        self.provider_filter.rpc_matches(log)
2177            && self
2178                .local_matcher
2179                .as_ref()
2180                .is_none_or(|matcher| matcher.matches(log))
2181    }
2182
2183    /// Extract the route key for a matching log, if configured.
2184    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
2185        self.route_key.as_ref().and_then(|spec| spec.extract(log))
2186    }
2187}
2188
2189impl fmt::Debug for LogInterest {
2190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2191        f.debug_struct("LogInterest")
2192            .field("provider_filter", &self.provider_filter)
2193            .field(
2194                "local_matcher",
2195                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2196            )
2197            .field("route_key", &self.route_key)
2198            .finish()
2199    }
2200}
2201
2202/// Local log predicate.
2203pub trait LogMatcher: Send + Sync {
2204    /// Return true when the log should be routed to the handler.
2205    fn matches(&self, log: &Log) -> bool;
2206}
2207
2208/// Route-key extraction strategy for logs.
2209#[derive(Clone)]
2210pub enum RouteKeySpec {
2211    /// Route by emitting address.
2212    EmitterAddress,
2213    /// Route by indexed topic.
2214    Topic {
2215        /// Topic index.
2216        index: usize,
2217    },
2218    /// Route by a byte slice in log data.
2219    DataSlice {
2220        /// Byte offset in the data payload.
2221        offset: usize,
2222        /// Number of bytes to copy.
2223        len: usize,
2224    },
2225    /// Custom extractor.
2226    Custom(Arc<dyn RouteKeyExtractor>),
2227}
2228
2229impl RouteKeySpec {
2230    /// Extract a route key from a log.
2231    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
2232        match self {
2233            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
2234            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
2235            Self::DataSlice { offset, len } => {
2236                let data = log.inner.data.data.as_ref();
2237                let end = offset.checked_add(*len)?;
2238                data.get(*offset..end)
2239                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
2240            }
2241            Self::Custom(extractor) => extractor.extract(log),
2242        }
2243    }
2244}
2245
2246impl fmt::Debug for RouteKeySpec {
2247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2248        match self {
2249            Self::EmitterAddress => f.write_str("EmitterAddress"),
2250            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
2251            Self::DataSlice { offset, len } => f
2252                .debug_struct("DataSlice")
2253                .field("offset", offset)
2254                .field("len", len)
2255                .finish(),
2256            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
2257        }
2258    }
2259}
2260
2261/// Extracts custom route keys from logs.
2262pub trait RouteKeyExtractor: Send + Sync {
2263    /// Extract a route key.
2264    fn extract(&self, log: &Log) -> Option<RouteKey>;
2265}
2266
2267/// Extracted route key.
2268#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2269pub enum RouteKey {
2270    /// Address key.
2271    Address(Address),
2272    /// 32-byte key.
2273    Bytes32(B256),
2274    /// Arbitrary bytes key.
2275    Bytes(Vec<u8>),
2276}
2277
2278/// Exact protocol-neutral key used to select candidate log handlers.
2279#[non_exhaustive]
2280#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2281pub enum LogRouteKey {
2282    /// Emitting contract address.
2283    Emitter(Address),
2284    /// Exact indexed topic.
2285    Topic {
2286        /// Topic position in the log.
2287        index: usize,
2288        /// Expected topic value.
2289        value: B256,
2290    },
2291    /// Exact byte slice in the log data.
2292    DataSlice {
2293        /// Byte offset in the data payload.
2294        offset: usize,
2295        /// Expected bytes.
2296        value: Vec<u8>,
2297    },
2298}
2299
2300/// Non-empty exhaustive OR-set of exact log route keys.
2301#[derive(Clone, Debug, PartialEq, Eq)]
2302pub struct LogRouteIndex {
2303    keys: Vec<LogRouteKey>,
2304}
2305
2306impl LogRouteIndex {
2307    /// Construct an index from one required key and optional additional keys.
2308    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
2309        let mut keys = vec![primary];
2310        for key in additional {
2311            if !keys.contains(&key) {
2312                keys.push(key);
2313            }
2314        }
2315        Self { keys }
2316    }
2317
2318    /// Construct a single-key index.
2319    pub fn single(key: LogRouteKey) -> Self {
2320        Self { keys: vec![key] }
2321    }
2322
2323    /// Exact keys in declaration order.
2324    pub fn keys(&self) -> &[LogRouteKey] {
2325        &self.keys
2326    }
2327}
2328
2329/// Exact log route selected by [`ReactiveRegistry::route_log`].
2330#[derive(Clone, Debug, PartialEq, Eq)]
2331pub struct ReactiveLogRoute {
2332    /// Handler whose log interest matched.
2333    pub handler_id: HandlerId,
2334    /// Optional route key extracted from the matching log interest.
2335    pub route_key: Option<RouteKey>,
2336}
2337
2338/// Interest in block inputs.
2339#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2340pub struct BlockInterest {
2341    /// Block input mode.
2342    pub mode: BlockInterestMode,
2343}
2344
2345impl Default for BlockInterest {
2346    fn default() -> Self {
2347        Self {
2348            mode: BlockInterestMode::Header,
2349        }
2350    }
2351}
2352
2353/// Block subscription mode.
2354#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2355pub enum BlockInterestMode {
2356    /// Header-only block input.
2357    Header,
2358    /// Full block input.
2359    FullBlock,
2360}
2361
2362/// Interest in pending transaction inputs.
2363#[derive(Clone)]
2364pub struct PendingTxInterest<N: Network = Ethereum> {
2365    /// Whether the handler requires full transaction bodies.
2366    pub full_transactions: bool,
2367    /// Sender matcher.
2368    pub from: AddressMatcher,
2369    /// Recipient matcher.
2370    pub to: AddressMatcher,
2371    /// Calldata selector matcher.
2372    pub selectors: SelectorMatcher,
2373    /// Optional local transaction matcher.
2374    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
2375}
2376
2377impl<N: Network> Default for PendingTxInterest<N> {
2378    fn default() -> Self {
2379        Self {
2380            full_transactions: false,
2381            from: AddressMatcher::Any,
2382            to: AddressMatcher::Any,
2383            selectors: SelectorMatcher::Any,
2384            local_matcher: None,
2385        }
2386    }
2387}
2388
2389impl<N: Network> PendingTxInterest<N> {
2390    fn matches_hash_only(&self) -> bool {
2391        !self.full_transactions
2392            && self.from.is_any()
2393            && self.to.is_any()
2394            && self.selectors.is_any()
2395            && self.local_matcher.is_none()
2396    }
2397
2398    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
2399        self.from.matches(tx.from())
2400            && self.to.matches_option(tx.to())
2401            && self.selectors.matches(tx.input())
2402            && self
2403                .local_matcher
2404                .as_ref()
2405                .is_none_or(|matcher| matcher.matches(tx))
2406    }
2407}
2408
2409impl<N: Network> fmt::Debug for PendingTxInterest<N> {
2410    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2411        f.debug_struct("PendingTxInterest")
2412            .field("full_transactions", &self.full_transactions)
2413            .field("from", &self.from)
2414            .field("to", &self.to)
2415            .field("selectors", &self.selectors)
2416            .field(
2417                "local_matcher",
2418                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2419            )
2420            .finish()
2421    }
2422}
2423
2424/// Address matching helper for pending transaction interests.
2425#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2426pub enum AddressMatcher {
2427    /// Match every address.
2428    Any,
2429    /// Match one address.
2430    Exact(Address),
2431    /// Match any address in the list.
2432    AnyOf(Vec<Address>),
2433}
2434
2435impl AddressMatcher {
2436    /// Return true when the matcher is unconstrained.
2437    pub fn is_any(&self) -> bool {
2438        matches!(self, Self::Any)
2439    }
2440
2441    /// Match a present address.
2442    pub fn matches(&self, address: Address) -> bool {
2443        match self {
2444            Self::Any => true,
2445            Self::Exact(expected) => *expected == address,
2446            Self::AnyOf(addresses) => addresses.contains(&address),
2447        }
2448    }
2449
2450    /// Match an optional address.
2451    pub fn matches_option(&self, address: Option<Address>) -> bool {
2452        match (self, address) {
2453            (Self::Any, _) => true,
2454            (_, Some(address)) => self.matches(address),
2455            _ => false,
2456        }
2457    }
2458}
2459
2460/// Calldata selector matching helper.
2461#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2462pub enum SelectorMatcher {
2463    /// Match every selector.
2464    Any,
2465    /// Match any selector in the list.
2466    AnyOf(Vec<[u8; 4]>),
2467}
2468
2469impl SelectorMatcher {
2470    /// Return true when the matcher is unconstrained.
2471    pub fn is_any(&self) -> bool {
2472        matches!(self, Self::Any)
2473    }
2474
2475    /// Match calldata bytes.
2476    pub fn matches(&self, input: &Bytes) -> bool {
2477        match self {
2478            Self::Any => true,
2479            Self::AnyOf(selectors) => input
2480                .get(..4)
2481                .and_then(|bytes| bytes.try_into().ok())
2482                .is_some_and(|selector| selectors.contains(&selector)),
2483        }
2484    }
2485}
2486
2487/// Local predicate over a full pending transaction.
2488pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2489    /// Return true when the transaction should be routed to the handler.
2490    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2491}
2492
2493/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2494///
2495/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2496/// liveness strategy is per-contract:
2497///
2498/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2499///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2500///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2501/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2502///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2503///   each canonical block; a move a decoder did not cover is a coverage gap.
2504///
2505/// A false-positive resync is never *incorrect* — it costs one batched read — so
2506/// the policy is a **pure cost knob**, not a correctness lever.
2507///
2508/// [`Slots`]: TrackingPolicy::Slots
2509/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2510#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2511#[non_exhaustive]
2512pub enum TrackingPolicy {
2513    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2514    /// nearly every block, so it is a noisy gate — this policy is **never**
2515    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2516    /// and cadence reconcile.
2517    Slots {
2518        /// The enumerated storage slots of interest.
2519        slots: Vec<U256>,
2520    },
2521    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2522    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2523    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2524    /// [`ResyncReason::RootMoved`] repair.
2525    WholeAccount,
2526    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2527    /// response's account fields; no storage interest. Native balance/nonce
2528    /// changes do **not** move the storage root, so this policy compares the
2529    /// account fields directly across blocks rather than root-gating.
2530    Scalars,
2531}
2532
2533/// How often the reactive root gate probes tracked accounts
2534/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2535/// `Scalars` account-fields comparison rides the same firing).
2536///
2537/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2538/// is never the default. Skipping blocks is safe by construction: the gate
2539/// diffs `root_now` against its **persisted baseline**, never
2540/// block-over-block, so a move in any skipped block is still visible at the
2541/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2542/// cost, never eventual detection. The decoder-touched set accumulates across
2543/// skipped blocks and drains per firing, so a covered write in a skipped
2544/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2545#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2546pub enum RootGateCadence {
2547    /// Probe at most once every `n` canonical blocks (the first canonical
2548    /// block ever seen always fires, so baseline adoption does not wait a
2549    /// full window). `EveryNBlocks(1)` is per-block probing.
2550    EveryNBlocks(NonZeroU64),
2551    /// Root gate off: coverage gaps surface only via decoders + freshness.
2552    Disabled,
2553}
2554
2555impl RootGateCadence {
2556    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2557    pub fn every_n_blocks(n: u64) -> Self {
2558        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2559    }
2560}
2561
2562impl Default for RootGateCadence {
2563    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2564    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2565    /// `n`, not lower it.
2566    fn default() -> Self {
2567        Self::every_n_blocks(16)
2568    }
2569}
2570
2571/// Per-account baseline held by the root gate: the last observed on-chain root
2572/// and account fields, plus the block they were observed at.
2573///
2574/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2575/// spec §6): it persists the *observed* root as a baseline and compares
2576/// `root_now` to it. This is a currency gate, not a completeness gate.
2577#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2578struct TrackedRoot {
2579    last_root: B256,
2580    last_block: u64,
2581    balance: U256,
2582    nonce: u64,
2583    code_hash: B256,
2584}
2585
2586/// Request for authoritative state repair.
2587#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2588pub struct ResyncRequest {
2589    /// Resync id.
2590    pub id: ResyncId,
2591    /// Reason for the request.
2592    pub reason: ResyncReason,
2593    /// Block selection for the read.
2594    pub block: ResyncBlock,
2595    /// Targets to resync.
2596    pub targets: Vec<ResyncTarget>,
2597    /// Scheduling priority.
2598    pub priority: ResyncPriority,
2599}
2600
2601/// Resync id.
2602#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2603pub struct ResyncId(String);
2604
2605impl ResyncId {
2606    /// Create a resync id.
2607    pub fn new(id: impl Into<String>) -> Self {
2608        Self(id.into())
2609    }
2610}
2611
2612/// Reason for a resync request.
2613#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2614#[non_exhaustive]
2615pub enum ResyncReason {
2616    /// Handler requested repair.
2617    HandlerRequested,
2618    /// State effect could not be applied completely.
2619    SkippedStateEffect,
2620    /// A missed block range was detected; caller-scheduled repair.
2621    ///
2622    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2623    /// range (there are no known targets to resync). This reason is provided so a
2624    /// caller building its own repair in response to a
2625    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2626    MissedBlockRange,
2627    /// A tracked account's storage root moved with no covering decoder.
2628    ///
2629    /// Emitted by the per-block root gate (Phase-8 step 4). A
2630    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2631    /// `storageHash` moved between the adopted baseline and the current canonical
2632    /// block, yet no decoder wrote that account during the block — a coverage gap.
2633    /// The gate schedules a resync with this reason to re-read the account
2634    /// authoritatively and self-heal the blind spot. Also used for the
2635    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2636    RootMoved,
2637    /// Caller-defined reason.
2638    Custom(String),
2639}
2640
2641/// Block target for a resync.
2642#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2643pub enum ResyncBlock {
2644    /// Latest block.
2645    Latest,
2646    /// Current provider pre-confirmation state.
2647    Pending,
2648    /// Safe head.
2649    Safe,
2650    /// Finalized head.
2651    Finalized,
2652    /// Block number.
2653    Number(u64),
2654    /// Block hash and number.
2655    Hash {
2656        /// Block number.
2657        number: u64,
2658        /// Block hash.
2659        hash: B256,
2660        /// Require the hash to still be canonical.
2661        require_canonical: bool,
2662    },
2663}
2664
2665/// State target for a resync.
2666#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2667pub enum ResyncTarget {
2668    /// One storage slot.
2669    StorageSlot {
2670        /// Contract address.
2671        address: Address,
2672        /// Storage slot.
2673        slot: U256,
2674    },
2675    /// Multiple storage slots on one contract.
2676    StorageSlots {
2677        /// Contract address.
2678        address: Address,
2679        /// Storage slots.
2680        slots: Vec<U256>,
2681    },
2682    /// Account fields.
2683    Account {
2684        /// Account address.
2685        address: Address,
2686        /// Fields to resync.
2687        fields: AccountFieldMask,
2688    },
2689}
2690
2691/// Account fields requested by a resync.
2692#[derive(
2693    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2694)]
2695pub struct AccountFieldMask {
2696    /// Balance field.
2697    pub balance: bool,
2698    /// Nonce field.
2699    pub nonce: bool,
2700    /// Code field.
2701    pub code: bool,
2702}
2703
2704/// Resync priority.
2705#[derive(
2706    Clone,
2707    Copy,
2708    Debug,
2709    Default,
2710    PartialEq,
2711    Eq,
2712    Hash,
2713    PartialOrd,
2714    Ord,
2715    serde::Serialize,
2716    serde::Deserialize,
2717)]
2718pub enum ResyncPriority {
2719    /// Low priority.
2720    Low,
2721    /// Normal priority.
2722    #[default]
2723    Normal,
2724    /// High priority.
2725    High,
2726}
2727
2728/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2729#[derive(Clone, Debug, PartialEq, Eq)]
2730pub struct InvalidationRequest {
2731    /// Purge scope.
2732    pub scope: PurgeScope,
2733    /// Address to purge.
2734    pub address: Address,
2735    /// Reason for reporting.
2736    pub reason: InvalidationReason,
2737}
2738
2739/// Invalidation reason.
2740#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2741pub enum InvalidationReason {
2742    /// Handler requested invalidation.
2743    HandlerRequested,
2744    /// Reorg invalidation.
2745    Reorg,
2746    /// Caller-defined reason.
2747    Custom(String),
2748}
2749
2750/// Speculative signal emitted by handlers.
2751#[derive(Clone, Debug, PartialEq, Eq)]
2752pub struct SpeculativeRequest {
2753    /// Speculative request id.
2754    pub id: SpeculativeId,
2755    /// Input that triggered the request.
2756    pub input_ref: InputRef,
2757    /// Labels for downstream routing.
2758    pub labels: Vec<ReportTag>,
2759}
2760
2761/// Speculative request id.
2762#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2763pub struct SpeculativeId(String);
2764
2765impl SpeculativeId {
2766    /// Create a speculative id.
2767    pub fn new(id: impl Into<String>) -> Self {
2768        Self(id.into())
2769    }
2770}
2771
2772/// Configuration for [`ReactiveRuntime`].
2773#[derive(Clone, Debug, PartialEq, Eq)]
2774pub struct ReactiveConfig {
2775    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2776    /// dispatch is synchronous today (every report is delivered to every hook in
2777    /// order), so this field is a no-op placeholder for a future async dispatcher.
2778    /// Setting it to anything other than the default does not change behavior.
2779    pub hook_backpressure: HookBackpressure,
2780    /// Reorg journal depth: the number of recent canonical blocks whose effects
2781    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2782    /// only blocks still resident in the journal can be recovered. A reorg deeper
2783    /// than `journal_depth` recovers the blocks still in the journal and leaves
2784    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2785    /// purged**, so the freshness/validation loop is the only backstop for that
2786    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2787    ///
2788    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2789    /// precisely. When a reorg references a block that is no longer in the journal,
2790    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2791    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2792    /// reorgs, implicit parent replacements, and removed/reorged records whose
2793    /// rollback proof falls outside the retained effect journal are rejected
2794    /// before mutation, durable save, or acknowledgement. Align this depth with
2795    /// the complete reorg horizon promised by the subscriber.
2796    pub journal_depth: usize,
2797}
2798
2799impl Default for ReactiveConfig {
2800    fn default() -> Self {
2801        Self {
2802            hook_backpressure: HookBackpressure::Block,
2803            journal_depth: 64,
2804        }
2805    }
2806}
2807
2808/// Hook backpressure policy.
2809#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2810pub enum HookBackpressure {
2811    /// Block the producer until hooks are accepted.
2812    Block,
2813    /// Drop the newest report under pressure.
2814    DropNewest,
2815    /// Drop the oldest report under pressure.
2816    DropOldest,
2817    /// Return an error under pressure.
2818    Error,
2819}
2820
2821/// Queryable coarse health of the reactive cache.
2822///
2823/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2824/// degraded or unhealthy state when it detects that its recovery guarantees no
2825/// longer hold (for example a reorg that runs deeper than the journal, so some
2826/// dropped effects are neither rolled back nor purged). Later waves report
2827/// missed-range and coverage-gap conditions into the same state machine.
2828#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2829#[non_exhaustive]
2830pub enum CacheHealth {
2831    /// All recovery guarantees hold; the cache is fully self-consistent.
2832    #[default]
2833    Healthy,
2834    /// A recoverable inconsistency was detected (for example under-recovered
2835    /// reorg effects); `since_block` records the block that triggered the
2836    /// transition.
2837    Degraded {
2838        /// Block number at which the degradation was first observed.
2839        since_block: u64,
2840    },
2841    /// A more serious inconsistency was detected; `since_block` records the
2842    /// block that triggered the transition.
2843    Unhealthy {
2844        /// Block number at which the unhealthy condition was first observed.
2845        since_block: u64,
2846    },
2847}
2848
2849/// Point-in-time copy of the reactive runtime's observability counters.
2850///
2851/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2852/// increasing count over the lifetime of the runtime. Counters wired by later
2853/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2854/// tracking) remain zero until those waves land.
2855#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2856#[non_exhaustive]
2857pub struct CacheMetricsSnapshot {
2858    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2859    /// rolled back or purged.
2860    pub deep_reorgs: u64,
2861    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2862    pub reorgs_recovered: u64,
2863    /// Storage resync targets considered by the resync execution pass.
2864    pub resync_requests: u64,
2865    /// Storage resync targets that could not be fetched or applied.
2866    pub resync_failures: u64,
2867    /// Ranges of blocks the runtime detected it did not observe (reserved).
2868    pub missed_ranges: u64,
2869    /// Storage-hash coverage gaps detected (reserved).
2870    pub coverage_gaps: u64,
2871    /// Pending-source inputs that attempted a canonical cache effect.
2872    pub pending_contamination: u64,
2873    /// Verdicts served past their freshness horizon (reserved).
2874    pub stale_verdicts: u64,
2875}
2876
2877/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2878///
2879/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2880/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2881/// into a plain [`CacheMetricsSnapshot`].
2882#[derive(Debug, Default)]
2883struct CacheMetrics {
2884    deep_reorgs: AtomicU64,
2885    reorgs_recovered: AtomicU64,
2886    resync_requests: AtomicU64,
2887    resync_failures: AtomicU64,
2888    missed_ranges: AtomicU64,
2889    coverage_gaps: AtomicU64,
2890    pending_contamination: AtomicU64,
2891    stale_verdicts: AtomicU64,
2892}
2893
2894impl CacheMetrics {
2895    fn snapshot(&self) -> CacheMetricsSnapshot {
2896        CacheMetricsSnapshot {
2897            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2898            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2899            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2900            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2901            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2902            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2903            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2904            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2905        }
2906    }
2907
2908    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2909        self.deep_reorgs
2910            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2911        self.reorgs_recovered
2912            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2913        self.resync_requests
2914            .store(snapshot.resync_requests, Ordering::Relaxed);
2915        self.resync_failures
2916            .store(snapshot.resync_failures, Ordering::Relaxed);
2917        self.missed_ranges
2918            .store(snapshot.missed_ranges, Ordering::Relaxed);
2919        self.coverage_gaps
2920            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2921        self.pending_contamination
2922            .store(snapshot.pending_contamination, Ordering::Relaxed);
2923        self.stale_verdicts
2924            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2925    }
2926}
2927
2928/// Runtime report.
2929#[derive(Clone, Debug)]
2930#[non_exhaustive]
2931pub enum ReactiveReport<N: Network = Ethereum> {
2932    /// Input was accepted after deduplication.
2933    Input(InputReport<N>),
2934    /// Handlers produced outcomes.
2935    Decoded(DecodedReport<N>),
2936    /// Direct state effects were applied.
2937    Applied(AppliedReport<N>),
2938    /// Resync request was scheduled or completed.
2939    Resynced(ResyncReport),
2940    /// Block-level processing completed.
2941    BlockCommitted(BlockReport<N>),
2942    /// Reorg processing report.
2943    Reorg(ReorgReport<N>),
2944    /// Ordered source control accepted by the runtime.
2945    ChainControl(ChainControlReport),
2946    /// A forward gap in the canonical block sequence was detected: blocks between
2947    /// the last-seen head and an arriving block were never observed.
2948    MissedBlockRange(MissedRangeReport<N>),
2949    /// Cache health transitioned between states.
2950    Health(HealthReport<N>),
2951    /// A tracked account's storage root moved with no covering decoder — a
2952    /// coverage gap the per-block root gate detected (Phase-8 step 4).
2953    CoverageGap(CoverageGapReport<N>),
2954    /// Runtime or handler error.
2955    Error(ReactiveErrorReport<N>),
2956}
2957
2958/// Report emitted after an ordered source control is accepted.
2959#[derive(Clone, Debug, PartialEq, Eq)]
2960pub struct ChainControlReport {
2961    /// Control in its original delivery order.
2962    pub control: ChainControl,
2963}
2964
2965/// Input acceptance report.
2966#[derive(Clone, Debug)]
2967pub struct InputReport<N: Network = Ethereum> {
2968    /// Input reference.
2969    pub input_ref: InputRef,
2970    /// Input context.
2971    pub context: ReactiveContext,
2972    /// Provider session that originated the input, when known.
2973    pub provider: Option<ProviderRef>,
2974    /// Network marker.
2975    pub _network: PhantomData<N>,
2976}
2977
2978/// Decoding report.
2979#[derive(Clone, Debug)]
2980pub struct DecodedReport<N: Network = Ethereum> {
2981    /// Input reference.
2982    pub input_ref: InputRef,
2983    /// Handler ids that matched the input.
2984    pub handler_ids: Vec<HandlerId>,
2985    /// Network marker.
2986    pub _network: PhantomData<N>,
2987}
2988
2989/// Applied state report.
2990#[derive(Clone, Debug)]
2991pub struct AppliedReport<N: Network = Ethereum> {
2992    /// Input reference.
2993    pub input_ref: InputRef,
2994    /// Handler that produced the applied effects.
2995    pub handler_id: HandlerId,
2996    /// State effect quality.
2997    pub quality: StateEffectQuality,
2998    /// Labels emitted by the handler.
2999    pub tags: Vec<ReportTag>,
3000    /// Merged state diff from applied updates and invalidations.
3001    pub diff: StateDiff,
3002    /// State updates applied through the cache.
3003    pub state_updates: Vec<StateUpdate>,
3004    /// Invalidation requests lowered to purge updates.
3005    pub invalidations: Vec<InvalidationRequest>,
3006    /// Resync requests surfaced for a scheduler.
3007    pub resyncs: Vec<ResyncRequest>,
3008    /// Speculative requests surfaced for downstream users.
3009    pub speculative: Vec<SpeculativeRequest>,
3010    /// Hook signals emitted by the handler.
3011    pub hook_signals: Vec<HookSignal>,
3012    /// Network marker.
3013    pub _network: PhantomData<N>,
3014}
3015
3016/// Report of the storage resync requests executed during an ingest cycle: the
3017/// requests considered, the authoritative updates built from successful fetches
3018/// (and their applied diff), and any targets that could not be resynced.
3019#[derive(Clone, Debug, Default, PartialEq, Eq)]
3020pub struct ResyncReport {
3021    /// Requests considered by the resync execution pass.
3022    pub requested: Vec<ResyncRequest>,
3023    /// Authoritative state updates built from successful resync fetches.
3024    pub state_updates: Vec<StateUpdate>,
3025    /// Diff returned by applying [`state_updates`](Self::state_updates).
3026    pub diff: StateDiff,
3027    /// Targets that could not be resynced.
3028    pub failed: Vec<ResyncFailure>,
3029}
3030
3031/// One resync target that could not be fetched or applied.
3032#[derive(Clone, Debug, PartialEq, Eq)]
3033pub struct ResyncFailure {
3034    /// Request that produced the failed target.
3035    pub request_id: ResyncId,
3036    /// Block selection used for the failed target.
3037    pub block: ResyncBlock,
3038    /// Target that could not be resynced.
3039    pub target: ResyncTarget,
3040    /// Stable failure classification for retry policy and metrics.
3041    pub kind: ResyncFailureKind,
3042    /// Human-readable failure reason.
3043    pub message: String,
3044}
3045
3046/// Stable classification for a failed resync target.
3047#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3048#[non_exhaustive]
3049pub enum ResyncFailureKind {
3050    /// A storage target could not be fetched because no storage batch fetcher is configured.
3051    MissingStorageFetcher,
3052    /// The storage batch fetcher returned an error for the requested slot.
3053    StorageFetchFailed,
3054    /// The storage batch fetcher did not return a result for the requested slot.
3055    StorageFetchOmitted,
3056    /// An account target could not be fetched because no account proof fetcher is configured.
3057    MissingAccountFetcher,
3058    /// The account proof fetcher returned an error for the requested address.
3059    AccountFetchFailed,
3060    /// The account proof fetcher did not return a result for the requested address.
3061    AccountFetchOmitted,
3062}
3063
3064/// Block processing report.
3065#[derive(Clone, Debug)]
3066pub struct BlockReport<N: Network = Ethereum> {
3067    /// Block reference, when known.
3068    pub block: Option<BlockRef>,
3069    /// Input references committed for the block.
3070    pub inputs: Vec<InputRef>,
3071    /// Network marker.
3072    pub _network: PhantomData<N>,
3073}
3074
3075/// Report of a detected reorg and the recovery it performed: the dropped
3076/// block(s) and inputs, the exact rollback updates applied for reversible dropped
3077/// effects, the conservative purge updates for irreversible ones, the canceled
3078/// hash-pinned resyncs, and why recovery ran.
3079///
3080/// Recovery only covers blocks still resident in the journal. If a reorg runs
3081/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
3082/// appear here and their effects are neither rolled back nor purged (the runtime
3083/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
3084/// backstop for that span. Checkpointed engine ingestion rejects explicit,
3085/// implicit-parent, and removed-log recovery outside the retained journal
3086/// instead of producing and durably acknowledging a partial report.
3087/// Non-checkpointed ingestion still emits this report when no journal entry was
3088/// recoverable; in that case `dropped` identifies the signal/head when known,
3089/// while `dropped_blocks` and rollback effects are empty.
3090#[derive(Clone, Debug)]
3091pub struct ReorgReport<N: Network = Ethereum> {
3092    /// First dropped block, when known.
3093    pub dropped: Option<BlockRef>,
3094    /// Blocks dropped from the journal, in ascending journal order.
3095    pub dropped_blocks: Vec<BlockRef>,
3096    /// Input references that belonged to dropped blocks.
3097    pub dropped_inputs: Vec<InputRef>,
3098    /// Exact rollback updates applied for reversible dropped effects.
3099    pub rollback_updates: Vec<StateUpdate>,
3100    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
3101    pub rollback_diff: StateDiff,
3102    /// Conservative purge updates applied for irreversible dropped effects.
3103    pub purge_updates: Vec<StateUpdate>,
3104    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
3105    pub purge_diff: StateDiff,
3106    /// Hash-pinned pending resync requests canceled because their block was dropped.
3107    pub canceled_resyncs: Vec<ResyncRequest>,
3108    /// Reorg trigger.
3109    pub reason: ReorgReason,
3110    /// Network marker.
3111    pub _network: PhantomData<N>,
3112}
3113
3114/// Reason reorg recovery ran.
3115#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3116pub enum ReorgReason {
3117    /// A provider emitted an Alloy removed log.
3118    RemovedLog,
3119    /// The input context explicitly marked an input as reorged.
3120    ReorgedInput,
3121    /// A canonical block did not connect to the journaled head.
3122    ParentMismatch,
3123    /// A subscriber delivered an explicit canonical branch transition.
3124    Explicit,
3125}
3126
3127/// Report of a forward gap in the canonical block sequence: an arriving block
3128/// whose number is more than one past the last-seen head, so the blocks in
3129/// between were never observed (for example during a subscription disconnect).
3130///
3131/// The arriving block is still accepted and applied — the chain extends — so this
3132/// report only makes the skipped span observable; it does not drop the block. The
3133/// span `from..=to` is inclusive of both endpoints.
3134#[derive(Clone, Debug)]
3135pub struct MissedRangeReport<N: Network = Ethereum> {
3136    /// First skipped block (`last-seen block number + 1`).
3137    pub from: u64,
3138    /// Last skipped block (`arriving block number - 1`).
3139    pub to: u64,
3140    /// The arriving block's number.
3141    pub block: u64,
3142    /// Network marker.
3143    pub _network: PhantomData<N>,
3144}
3145
3146/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
3147/// caused it and delivered to hooks through the normal dispatch path.
3148#[derive(Clone, Debug)]
3149pub struct HealthReport<N: Network = Ethereum> {
3150    /// Health state before the transition.
3151    pub from: CacheHealth,
3152    /// Health state after the transition.
3153    pub to: CacheHealth,
3154    /// Block number associated with the transition, when known.
3155    pub block: Option<u64>,
3156    /// Network marker.
3157    pub _network: PhantomData<N>,
3158}
3159
3160/// Report that a tracked account's storage root moved on a canonical block that
3161/// no decoder covered — a coverage gap surfaced by the per-block root gate
3162/// (Phase-8 step 4).
3163///
3164/// An account's `storageHash` is a collision-resistant commitment over all of its
3165/// storage, so a moved root proves *something* under the account changed. When
3166/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
3167/// batch's touched-address set does not include it, the change arrived through a
3168/// path no decoder observed. The runtime emits this report (delivered through the
3169/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
3170/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
3171/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
3172#[derive(Clone, Debug)]
3173pub struct CoverageGapReport<N: Network = Ethereum> {
3174    /// The tracked account whose root moved with no covering decoder.
3175    pub address: Address,
3176    /// The canonical block number at which the gap was observed.
3177    pub block: u64,
3178    /// Network marker.
3179    pub _network: PhantomData<N>,
3180}
3181
3182/// Report of a non-fatal error surfaced during an ingest cycle, with the
3183/// associated input (when known) and a human-readable message.
3184#[derive(Clone, Debug)]
3185pub struct ReactiveErrorReport<N: Network = Ethereum> {
3186    /// Input associated with the error, when known.
3187    pub input_ref: Option<InputRef>,
3188    /// Error message.
3189    pub message: String,
3190    /// Network marker.
3191    pub _network: PhantomData<N>,
3192}
3193
3194/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
3195/// [`ReactiveRuntime::ingest_batch_with_resync`].
3196#[derive(Clone, Debug)]
3197pub struct ReactiveBatchReport<N: Network = Ethereum> {
3198    /// Applied reports in commit order.
3199    pub applied: Vec<AppliedReport<N>>,
3200    /// Resync requests surfaced during the batch.
3201    pub resyncs: Vec<ResyncRequest>,
3202    /// Speculative requests surfaced during the batch.
3203    pub speculative: Vec<SpeculativeRequest>,
3204    /// Hook reports dispatched after mutation phases.
3205    pub reports: Vec<Arc<ReactiveReport<N>>>,
3206}
3207
3208impl<N: Network> Default for ReactiveBatchReport<N> {
3209    fn default() -> Self {
3210        Self {
3211            applied: Vec::new(),
3212            resyncs: Vec::new(),
3213            speculative: Vec::new(),
3214            reports: Vec::new(),
3215        }
3216    }
3217}
3218
3219/// Error returned by a handler.
3220#[derive(Clone, Debug, PartialEq, Eq)]
3221pub struct HandlerError {
3222    message: String,
3223}
3224
3225impl HandlerError {
3226    /// Create a handler error from a message.
3227    pub fn new(message: impl Into<String>) -> Self {
3228        Self {
3229            message: message.into(),
3230        }
3231    }
3232}
3233
3234impl fmt::Display for HandlerError {
3235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3236        self.message.fmt(f)
3237    }
3238}
3239
3240impl std::error::Error for HandlerError {}
3241
3242impl From<String> for HandlerError {
3243    fn from(message: String) -> Self {
3244        Self::new(message)
3245    }
3246}
3247
3248impl From<&str> for HandlerError {
3249    fn from(message: &str) -> Self {
3250        Self::new(message)
3251    }
3252}
3253
3254/// Runtime error.
3255#[derive(Debug, thiserror::Error)]
3256#[non_exhaustive]
3257pub enum ReactiveError {
3258    /// Handler returned an error.
3259    #[error("handler `{handler_id}` failed: {source}")]
3260    HandlerFailed {
3261        /// Handler id.
3262        handler_id: HandlerId,
3263        /// Handler error.
3264        source: HandlerError,
3265    },
3266    /// Multiple handlers emitted incompatible absolute writes for one input.
3267    #[error(
3268        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
3269    )]
3270    ConflictingEffects {
3271        /// Input reference.
3272        input_ref: Box<InputRef>,
3273        /// Conflicting target.
3274        target: Box<EffectTarget>,
3275        /// First handler id.
3276        first: HandlerId,
3277        /// Second handler id.
3278        second: HandlerId,
3279    },
3280    /// Pending inputs attempted to mutate canonical cache state.
3281    #[error(
3282        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
3283    )]
3284    InvalidPendingEffect {
3285        /// Input reference.
3286        input_ref: Box<InputRef>,
3287        /// Handler id.
3288        handler_id: HandlerId,
3289        /// Effect kind.
3290        effect_kind: &'static str,
3291    },
3292    /// A subscriber supplied payload metadata that is incomplete or
3293    /// contradicts the accompanying context.
3294    #[error("invalid reactive input record: {message}")]
3295    InvalidInputRecord {
3296        /// Human-readable invariant violation.
3297        message: String,
3298    },
3299    /// A source delivered a contradictory chain-lifecycle transition.
3300    #[error("invalid chain control: {message}")]
3301    InvalidChainControl {
3302        /// Human-readable invariant violation.
3303        message: String,
3304    },
3305    /// Owner-scoped catch-up would mutate a historical block for which the
3306    /// runtime has no rollback journal entry.
3307    #[error(
3308        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3309    )]
3310    OwnerCatchupOutsideJournal {
3311        /// Catch-up block number.
3312        number: u64,
3313        /// Catch-up block hash.
3314        hash: B256,
3315    },
3316    /// Registration error.
3317    #[error(transparent)]
3318    Register(#[from] RegisterError),
3319}
3320
3321/// Handler registration error.
3322#[derive(Debug, thiserror::Error)]
3323#[non_exhaustive]
3324pub enum RegisterError {
3325    /// Duplicate handler id.
3326    #[error("handler id `{0}` is already registered")]
3327    DuplicateHandler(HandlerId),
3328}
3329
3330/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
3331/// runtime and subscriber sides.
3332#[derive(Debug, thiserror::Error)]
3333#[non_exhaustive]
3334pub enum ReactiveEngineRegisterError {
3335    /// Runtime registry rejected the handler.
3336    #[error(transparent)]
3337    Register(#[from] RegisterError),
3338    /// Subscriber rejected the handler's interests.
3339    #[error(transparent)]
3340    Subscriber(#[from] SubscriberError),
3341    /// Owner-only history was not constrained to one hash-certified block that
3342    /// remains in the runtime rollback journal.
3343    #[error(
3344        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
3345    )]
3346    BackfillOutsideJournal {
3347        /// First requested block.
3348        start_block: u64,
3349        /// Inclusive requested upper bound, if bounded.
3350        end_block: Option<u64>,
3351        /// Hash-certified anchor supplied by the caller, if any.
3352        retained_anchor: Option<BlockRef>,
3353    },
3354}
3355
3356/// Error adopting an RPC snapshot as a runtime's canonical continuity
3357/// baseline.
3358#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
3359#[non_exhaustive]
3360pub enum ReactiveBaselineError {
3361    /// Runtime or engine delivery state already contains lifecycle work.
3362    #[error("cannot adopt a canonical baseline after reactive processing has started")]
3363    ActiveRuntime,
3364    /// An exact repeat is allowed, but the requested baseline conflicts with
3365    /// the previously adopted block.
3366    #[error(
3367        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
3368    )]
3369    ConflictingBaseline {
3370        /// Existing baseline number.
3371        existing_number: u64,
3372        /// Existing baseline hash.
3373        existing_hash: B256,
3374        /// Requested baseline number.
3375        requested_number: u64,
3376        /// Requested baseline hash.
3377        requested_hash: B256,
3378    },
3379    /// Typed baseline and cache identify different chains.
3380    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
3381    CacheChainMismatch {
3382        /// Chain declared by the baseline.
3383        baseline_chain_id: u64,
3384        /// Chain configured on the cache.
3385        cache_chain_id: u64,
3386    },
3387    /// The cache is not hash-pinned to the exact adopted canonical block.
3388    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
3389    CacheBlockMismatch {
3390        /// Expected baseline number.
3391        number: u64,
3392        /// Expected baseline hash.
3393        hash: B256,
3394    },
3395}
3396
3397/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
3398/// and runtime ingestion.
3399#[derive(Debug, thiserror::Error)]
3400#[non_exhaustive]
3401pub enum ReactiveEngineError {
3402    /// Subscriber polling failed.
3403    #[error(transparent)]
3404    Subscriber(#[from] SubscriberError),
3405    /// Runtime ingestion failed.
3406    #[error(transparent)]
3407    Runtime(ReactiveError),
3408    /// Canonical cold-start baseline adoption failed.
3409    #[error(transparent)]
3410    Baseline(#[from] ReactiveBaselineError),
3411    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
3412    /// did not commit. The subscriber may replay the batch.
3413    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3414    Acknowledgement(#[source] SubscriberError),
3415    /// Runtime ingestion succeeded, but the resulting cache state could not be
3416    /// durably checkpointed. The engine retains the commit in memory and must
3417    /// retry it before polling another batch.
3418    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3419    Checkpoint(#[source] DurableCheckpointError),
3420    /// A checkpointed ingest had no canonical block to bind the state to.
3421    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3422    MissingCheckpointBlock,
3423    /// Speculative pre-confirmation state is intentionally excluded from
3424    /// canonical durable checkpoints.
3425    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3426    PreconfirmationNotCheckpointable,
3427    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3428    #[error("failed to encode durable reactive runtime state: {0}")]
3429    RuntimeCheckpoint(String),
3430    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3431    /// to ordinary acknowledgement ordering without first completing it.
3432    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3433    PendingCheckpointCommit,
3434    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3435    /// switch to checkpointed ingestion and retroactively make it durable.
3436    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3437    PendingAcknowledgementCommit,
3438    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3439    /// commit metadata. Only the combined polling helpers can preserve the
3440    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3441    #[error(
3442        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3443    )]
3444    UncommittedDeliveryMetadata,
3445    /// Subscriber and cache are bound to different chains.
3446    #[error(
3447        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3448    )]
3449    SubscriberChainMismatch {
3450        /// Chain reported by the subscriber.
3451        subscriber_chain_id: u64,
3452        /// Chain configured on the cache.
3453        cache_chain_id: u64,
3454    },
3455    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3456    #[error("subscriber does not advertise durable replay support")]
3457    SubscriberNotDurable,
3458    /// A restored delivery token predates or otherwise lacks the core witness
3459    /// needed to prove that a replay carries the same delivery.
3460    #[error(
3461        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3462    )]
3463    MissingReplayWitness,
3464    /// A source reused a committed token for different records, routing,
3465    /// controls, chain identity, or provider resume state.
3466    #[error("replayed delivery token does not match its committed delivery witness")]
3467    ReplayDeliveryMismatch,
3468    /// The stable delivery witness could not be encoded.
3469    #[error("failed to encode durable delivery witness: {0}")]
3470    DeliveryWitness(String),
3471    /// A tokened network-generic header/body cannot be witnessed completely
3472    /// without a source-supplied canonical wire commitment.
3473    #[error(
3474        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3475    )]
3476    MissingPayloadCommitment,
3477    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3478    /// would bind those unrelated mutations to the older delivery metadata.
3479    #[error(
3480        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3481    )]
3482    PendingCheckpointCacheChanged {
3483        /// Generation immediately after the staged batch was ingested.
3484        staged_generation: u64,
3485        /// Generation observed when checkpoint commit was retried.
3486        current_generation: u64,
3487    },
3488    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3489    /// runtime no longer retains every potentially affected journal entry.
3490    #[error(
3491        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3492    )]
3493    CheckpointReorgOutsideJournal {
3494        /// Last block shared by the old and replacement branches.
3495        common_ancestor: u64,
3496        /// Oldest retained effect-bearing journal block, if any.
3497        oldest_journaled: Option<u64>,
3498        /// Configured maximum journal entries.
3499        journal_depth: usize,
3500    },
3501    /// Owner-scoped catch-up would mutate a historical block for which the
3502    /// runtime has no rollback journal entry.
3503    #[error(
3504        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3505    )]
3506    OwnerCatchupOutsideJournal {
3507        /// Catch-up block number.
3508        number: u64,
3509        /// Catch-up block hash.
3510        hash: B256,
3511    },
3512}
3513
3514impl From<ReactiveError> for ReactiveEngineError {
3515    fn from(error: ReactiveError) -> Self {
3516        match error {
3517            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3518                Self::OwnerCatchupOutsideJournal { number, hash }
3519            }
3520            error => Self::Runtime(error),
3521        }
3522    }
3523}
3524
3525/// Error restoring a durable checkpoint anchor into an active runtime.
3526#[derive(Debug, thiserror::Error)]
3527#[non_exhaustive]
3528pub enum ReactiveCheckpointRestoreError {
3529    /// A runtime with canonical journal state cannot be silently rewound.
3530    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3531    ActiveRuntime,
3532    /// Stored runtime recovery bytes were malformed or unsupported.
3533    #[error("invalid durable reactive runtime state: {0}")]
3534    InvalidRuntimeCheckpoint(String),
3535    /// Checkpoint identity or cache restoration failed before activation.
3536    #[error(transparent)]
3537    Checkpoint(#[from] DurableCheckpointError),
3538    /// Subscriber rejected the restored durable cursor or canonical position.
3539    #[error("subscriber rejected durable resume position: {0}")]
3540    Subscriber(#[source] SubscriberError),
3541    /// Subscriber and checkpoint identities name different chains.
3542    #[error(
3543        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3544    )]
3545    SubscriberChainMismatch {
3546        /// Chain reported by the subscriber.
3547        subscriber_chain_id: u64,
3548        /// Chain committed by the checkpoint identity.
3549        checkpoint_chain_id: u64,
3550    },
3551    /// Restoring event continuity requires a durable replay-capable subscriber.
3552    #[error("subscriber does not advertise durable replay support")]
3553    SubscriberNotDurable,
3554}
3555
3556/// Result of one crash-safe subscriber ingest cycle.
3557#[derive(Clone, Debug)]
3558#[non_exhaustive]
3559pub enum CheckpointedIngest<N: Network = Ethereum> {
3560    /// A new batch was ingested, durably checkpointed, and acknowledged.
3561    Applied(ReactiveBatchReport<N>),
3562    /// The checkpoint already contained this replayed delivery token, so the
3563    /// batch was acknowledged without applying its effects twice.
3564    ReplayAcknowledged,
3565}
3566
3567/// Absolute write target used for conflict reports.
3568#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3569pub enum EffectTarget {
3570    /// Storage slot target.
3571    StorageSlot {
3572        /// Contract address.
3573        address: Address,
3574        /// Storage slot.
3575        slot: U256,
3576    },
3577    /// Account balance target.
3578    AccountBalance {
3579        /// Account address.
3580        address: Address,
3581    },
3582    /// Account nonce target.
3583    AccountNonce {
3584        /// Account address.
3585        address: Address,
3586    },
3587    /// Account code target.
3588    AccountCode {
3589        /// Account address.
3590        address: Address,
3591    },
3592    /// Masked storage slot target.
3593    MaskedStorageSlot {
3594        /// Contract address.
3595        address: Address,
3596        /// Storage slot.
3597        slot: U256,
3598        /// Bit mask.
3599        mask: U256,
3600    },
3601}
3602
3603#[derive(Clone, Debug, PartialEq, Eq)]
3604enum AbsoluteValue {
3605    U256(U256),
3606    U64(u64),
3607    Bytes(Bytes),
3608}
3609
3610/// Reactive runtime.
3611pub struct ReactiveRuntime<N: Network = Ethereum> {
3612    registry: ReactiveRegistry<N>,
3613    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3614    config: ReactiveConfig,
3615    journal: VecDeque<BlockJournal<N>>,
3616    coverage_head: Option<BlockRef>,
3617    pending_resyncs: Vec<ResyncRequest>,
3618    health: CacheHealth,
3619    safe_head: Option<BlockRef>,
3620    finalized_head: Option<BlockRef>,
3621    metrics: CacheMetrics,
3622    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3623    ///
3624    /// `None` by default (behavior unchanged); populated by
3625    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3626    /// present, applying a canonical handler storage-slot effect stamps the
3627    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3628    /// so event-maintained slots stop being needlessly re-verified while aging to
3629    /// volatile once the clock passes `N`.
3630    freshness: Option<FreshnessRegistry>,
3631    /// Per-account tracking registry consulted by the per-block root gate
3632    /// (Phase-8 step 4). Empty by default; populated by
3633    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3634    tracking: HashMap<Address, TrackingPolicy>,
3635    /// Per-account root/field baselines the gate diffs against across blocks.
3636    /// Adopted on first probe and re-adopted on every observed move.
3637    tracked_roots: HashMap<Address, TrackedRoot>,
3638    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3639    root_gate_cadence: RootGateCadence,
3640    /// Canonical block of the last root-gate firing. `None` until the first
3641    /// firing (which happens at the first canonical block ever seen, so
3642    /// baseline adoption never waits a full cadence window).
3643    last_gate_block: Option<u64>,
3644    /// Union of decoder-touched addresses since the last root-gate firing,
3645    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3646    /// touched" must judge against every covered write in the window, or a
3647    /// decoder-covered write in a skipped block would false-positive as a
3648    /// [`ReactiveReport::CoverageGap`].
3649    touched_since_gate: HashSet<Address>,
3650    /// Disposable pre-confirmation branch layered over the canonical cache.
3651    /// This is deliberately omitted from durable runtime checkpoints.
3652    preconfirmed_branch: Option<PreconfirmedBranch>,
3653}
3654
3655#[derive(Clone)]
3656struct PreconfirmedBranch {
3657    flashblock: FlashblockRef,
3658    canonical_cache: EvmCacheStateSnapshot,
3659}
3660
3661#[derive(Clone, Debug)]
3662struct BlockJournal<N: Network = Ethereum> {
3663    block: BlockRef,
3664    inputs: Vec<InputRef>,
3665    applied: Vec<AppliedReport<N>>,
3666    handler_ids: Vec<HandlerId>,
3667    resynced: Vec<ResyncReport>,
3668    rollback_diffs: Vec<StateDiff>,
3669}
3670
3671const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3;
3672
3673#[derive(serde::Serialize, serde::Deserialize)]
3674struct DurableRuntimeCheckpoint {
3675    version: u32,
3676    safe_head: Option<BlockRef>,
3677    finalized_head: Option<BlockRef>,
3678    health: CacheHealth,
3679    pending_resyncs: Vec<ResyncRequest>,
3680    coverage_head: Option<BlockRef>,
3681    journal: Vec<DurableBlockJournal>,
3682    freshness: Option<FreshnessRegistry>,
3683    tracking: HashMap<Address, TrackingPolicy>,
3684    tracked_roots: HashMap<Address, TrackedRoot>,
3685    root_gate_cadence: RootGateCadence,
3686    last_gate_block: Option<u64>,
3687    touched_since_gate: HashSet<Address>,
3688    metrics: CacheMetricsSnapshot,
3689}
3690
3691#[derive(serde::Serialize, serde::Deserialize)]
3692struct DurableBlockJournal {
3693    block: BlockRef,
3694    handler_ids: Vec<HandlerId>,
3695    rollback_diffs: Vec<StateDiff>,
3696}
3697
3698struct DurableRuntimeRestorePlan {
3699    checkpoint: Option<DurableRuntimeCheckpoint>,
3700    fallback_history: Vec<BlockRef>,
3701}
3702
3703impl DurableRuntimeRestorePlan {
3704    fn canonical_history(&self) -> Vec<BlockRef> {
3705        self.checkpoint.as_ref().map_or_else(
3706            || self.fallback_history.clone(),
3707            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3708        )
3709    }
3710}
3711
3712#[derive(Clone)]
3713struct ReactiveRuntimeState<N: Network> {
3714    journal: VecDeque<BlockJournal<N>>,
3715    coverage_head: Option<BlockRef>,
3716    pending_resyncs: Vec<ResyncRequest>,
3717    health: CacheHealth,
3718    safe_head: Option<BlockRef>,
3719    finalized_head: Option<BlockRef>,
3720    freshness: Option<FreshnessRegistry>,
3721    tracking: HashMap<Address, TrackingPolicy>,
3722    tracked_roots: HashMap<Address, TrackedRoot>,
3723    root_gate_cadence: RootGateCadence,
3724    last_gate_block: Option<u64>,
3725    touched_since_gate: HashSet<Address>,
3726    metrics: CacheMetricsSnapshot,
3727}
3728
3729#[derive(Clone)]
3730struct ChainControlState {
3731    journal_invalidated_from: Option<u64>,
3732    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3733}
3734
3735/// Canonical branch fragments already rolled back by the current atomic batch.
3736///
3737/// Providers commonly emit one removed notification per log after one signal
3738/// has already drained the complete dropped block (and every retained
3739/// descendant). Explicit reorg controls can be followed by the same redundant
3740/// lifecycle records. Exact identities decide whether removal recovery is
3741/// redundant; numeric spans are retained only as same-batch proof for a
3742/// parentless replacement after those exact journal entries were drained.
3743#[derive(Default)]
3744struct BatchDroppedCanonical {
3745    identities: HashSet<(u64, B256)>,
3746    implicit_spans: Vec<(u64, u64)>,
3747}
3748
3749impl BatchDroppedCanonical {
3750    fn covers_implicit_number(&self, number: u64) -> bool {
3751        self.implicit_spans
3752            .iter()
3753            .any(|(from, through)| number >= *from && number <= *through)
3754    }
3755
3756    fn contains(&self, block: &BlockRef) -> bool {
3757        self.identities.contains(&(block.number, block.hash))
3758    }
3759
3760    fn record_identity(&mut self, block: &BlockRef) {
3761        self.identities.insert((block.number, block.hash));
3762    }
3763
3764    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3765        self.identities.insert((old_tip.number, old_tip.hash));
3766    }
3767
3768    fn record_drained(&mut self, blocks: &[BlockRef]) {
3769        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3770            return;
3771        };
3772        let through = blocks
3773            .iter()
3774            .map(|block| block.number)
3775            .max()
3776            .expect("a non-empty drained set has a maximum");
3777        self.implicit_spans.push((from, through));
3778        self.identities
3779            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3780    }
3781}
3782
3783/// Registry and router for provider-neutral reactive handlers.
3784///
3785/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3786/// consolidated provider-side log filters for subscription setup, and routes
3787/// provider logs back to the exact matching log interests. Consolidated filters
3788/// may be safe supersets; [`Self::route_log`] always re-checks the original
3789/// [`LogInterest`] and its local matcher before returning a route.
3790pub struct ReactiveRegistry<N: Network = Ethereum> {
3791    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3792    handler_positions: HashMap<HandlerId, u128>,
3793    next_handler_position: u128,
3794    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3795    fallback_log_handlers: BTreeSet<u128>,
3796    data_slice_shapes: HashMap<(usize, usize), usize>,
3797}
3798
3799struct RegisteredHandler<N: Network = Ethereum> {
3800    id: HandlerId,
3801    handler: Arc<dyn ReactiveHandler<N>>,
3802    interests: Vec<ReactiveInterest<N>>,
3803    has_log_interests: bool,
3804    log_route_index: Option<LogRouteIndex>,
3805}
3806
3807impl<N: Network> Default for ReactiveRegistry<N> {
3808    fn default() -> Self {
3809        Self::new()
3810    }
3811}
3812
3813impl<N: Network> ReactiveRegistry<N> {
3814    /// Create an empty registry.
3815    pub fn new() -> Self {
3816        Self {
3817            handlers: BTreeMap::new(),
3818            handler_positions: HashMap::new(),
3819            next_handler_position: 0,
3820            indexed_log_handlers: HashMap::new(),
3821            fallback_log_handlers: BTreeSet::new(),
3822            data_slice_shapes: HashMap::new(),
3823        }
3824    }
3825
3826    /// Register a handler, preserving registration order.
3827    ///
3828    /// Duplicate handler ids are rejected with
3829    /// [`RegisterError::DuplicateHandler`].
3830    ///
3831    /// # Errors
3832    ///
3833    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3834    /// registered.
3835    pub fn register_handler(
3836        &mut self,
3837        handler: Arc<dyn ReactiveHandler<N>>,
3838    ) -> Result<(), RegisterError> {
3839        let id = handler.id();
3840        if self.handler_positions.contains_key(&id) {
3841            return Err(RegisterError::DuplicateHandler(id));
3842        }
3843        let interests = handler.interests();
3844        self.insert_handler_prepared(id, handler, interests);
3845        Ok(())
3846    }
3847
3848    fn insert_handler_prepared(
3849        &mut self,
3850        id: HandlerId,
3851        handler: Arc<dyn ReactiveHandler<N>>,
3852        interests: Vec<ReactiveInterest<N>>,
3853    ) {
3854        debug_assert!(!self.handler_positions.contains_key(&id));
3855        let has_log_interests = interests
3856            .iter()
3857            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3858        let log_route_index = handler.log_route_index();
3859        if self.next_handler_position == u128::MAX {
3860            self.compact_handler_positions();
3861        }
3862        let position = self.next_handler_position;
3863        self.next_handler_position += 1;
3864        self.handler_positions.insert(id.clone(), position);
3865        if let Some(index) = &log_route_index {
3866            for key in index.keys() {
3867                if let LogRouteKey::DataSlice { offset, value } = key {
3868                    *self
3869                        .data_slice_shapes
3870                        .entry((*offset, value.len()))
3871                        .or_default() += 1;
3872                }
3873                self.indexed_log_handlers
3874                    .entry(key.clone())
3875                    .or_default()
3876                    .insert(position);
3877            }
3878        } else if has_log_interests {
3879            self.fallback_log_handlers.insert(position);
3880        }
3881        self.handlers.insert(
3882            position,
3883            RegisteredHandler {
3884                id,
3885                handler,
3886                interests,
3887                has_log_interests,
3888                log_route_index,
3889            },
3890        );
3891    }
3892
3893    /// Remove one handler by id, leaving all other handlers and interests intact.
3894    ///
3895    /// Returns the removed handler when the id was registered. Cache eviction is
3896    /// intentionally outside this API: unregistering stops future routing and
3897    /// decode for the handler only.
3898    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3899        let position = self.handler_positions.remove(id)?;
3900        let registered = self.handlers.remove(&position)?;
3901        if let Some(index) = &registered.log_route_index {
3902            for key in index.keys() {
3903                let remove_bucket = self
3904                    .indexed_log_handlers
3905                    .get_mut(key)
3906                    .is_some_and(|owners| {
3907                        owners.remove(&position);
3908                        owners.is_empty()
3909                    });
3910                if remove_bucket {
3911                    self.indexed_log_handlers.remove(key);
3912                }
3913                if let LogRouteKey::DataSlice { offset, value } = key {
3914                    let shape = (*offset, value.len());
3915                    let remove_shape =
3916                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3917                            *count -= 1;
3918                            *count == 0
3919                        });
3920                    if remove_shape {
3921                        self.data_slice_shapes.remove(&shape);
3922                    }
3923                }
3924            }
3925        } else {
3926            self.fallback_log_handlers.remove(&position);
3927        }
3928        Some(registered.handler)
3929    }
3930
3931    /// Return true when `id` is currently registered.
3932    pub fn contains_handler(&self, id: &HandlerId) -> bool {
3933        self.handler_positions.contains_key(id)
3934    }
3935
3936    /// Ids of all registered handlers, in registration (= routing) order.
3937    pub fn handler_ids(&self) -> Vec<HandlerId> {
3938        self.handlers
3939            .values()
3940            .map(|handler| handler.id.clone())
3941            .collect()
3942    }
3943
3944    /// Borrow the interests owned by one handler.
3945    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
3946        self.handler_positions
3947            .get(id)
3948            .and_then(|position| self.handlers.get(position))
3949            .map(|registered| registered.interests.as_slice())
3950    }
3951
3952    /// Return all registered interests in handler registration order.
3953    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
3954        self.handlers
3955            .values()
3956            .flat_map(|handler| handler.interests.clone())
3957            .collect()
3958    }
3959
3960    /// Return consolidated provider-side log filters.
3961    ///
3962    /// Filters are emitted in deterministic first-registration order by
3963    /// compatible block option. Within each returned filter, address and topic
3964    /// sets are unioned independently, which can intentionally overfetch. Use
3965    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
3966    pub fn log_subscription_filters(&self) -> Vec<Filter> {
3967        let mut filters = Vec::new();
3968        for interest in self.log_interests() {
3969            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
3970        }
3971        filters
3972    }
3973
3974    /// Route a log to exact matching handler interests.
3975    ///
3976    /// Routes are returned in handler registration order. Each handler appears
3977    /// at most once for a log, using the first matching log interest declared by
3978    /// that handler.
3979    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
3980        self.log_handler_candidates(log)
3981            .into_iter()
3982            .filter_map(|handler| handler.route_log(log))
3983            .collect()
3984    }
3985
3986    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
3987        let mut indexed_positions = Vec::new();
3988        if let Some(indexed) = self
3989            .indexed_log_handlers
3990            .get(&LogRouteKey::Emitter(log.address()))
3991        {
3992            indexed_positions.extend(indexed.iter().copied());
3993        }
3994        for (index, value) in log.topics().iter().copied().enumerate() {
3995            if let Some(indexed) = self
3996                .indexed_log_handlers
3997                .get(&LogRouteKey::Topic { index, value })
3998            {
3999                indexed_positions.extend(indexed.iter().copied());
4000            }
4001        }
4002        let data = log.inner.data.data.as_ref();
4003        for &(offset, len) in self.data_slice_shapes.keys() {
4004            let Some(end) = offset.checked_add(len) else {
4005                continue;
4006            };
4007            let Some(value) = data.get(offset..end) else {
4008                continue;
4009            };
4010            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
4011                offset,
4012                value: value.to_vec(),
4013            }) {
4014                indexed_positions.extend(indexed.iter().copied());
4015            }
4016        }
4017        if indexed_positions.is_empty() {
4018            if self.fallback_log_handlers.is_empty() {
4019                return Vec::new();
4020            }
4021            if !self.indexed_log_handlers.is_empty() {
4022                return self
4023                    .fallback_log_handlers
4024                    .iter()
4025                    .filter_map(|position| self.handlers.get(position))
4026                    .collect();
4027            }
4028            return self
4029                .handlers
4030                .values()
4031                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
4032                .collect();
4033        }
4034
4035        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
4036        indexed_positions.sort_unstable();
4037        indexed_positions.dedup();
4038        indexed_positions
4039            .into_iter()
4040            .filter_map(|position| self.handlers.get(&position))
4041            .collect()
4042    }
4043
4044    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
4045        self.handlers.values()
4046    }
4047
4048    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
4049        self.handlers.values().flat_map(|handler| {
4050            handler
4051                .interests
4052                .iter()
4053                .filter_map(|interest| match interest {
4054                    ReactiveInterest::Logs(interest) => Some(interest),
4055                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
4056                })
4057        })
4058    }
4059
4060    fn compact_handler_positions(&mut self) {
4061        let handlers = std::mem::take(&mut self.handlers);
4062        self.handler_positions.clear();
4063        self.indexed_log_handlers.clear();
4064        self.fallback_log_handlers.clear();
4065        self.data_slice_shapes.clear();
4066
4067        for (position, (_, handler)) in handlers.into_iter().enumerate() {
4068            let position = position as u128;
4069            self.handler_positions.insert(handler.id.clone(), position);
4070            if let Some(index) = &handler.log_route_index {
4071                for key in index.keys() {
4072                    if let LogRouteKey::DataSlice { offset, value } = key {
4073                        *self
4074                            .data_slice_shapes
4075                            .entry((*offset, value.len()))
4076                            .or_default() += 1;
4077                    }
4078                    self.indexed_log_handlers
4079                        .entry(key.clone())
4080                        .or_default()
4081                        .insert(position);
4082                }
4083            } else if handler.has_log_interests {
4084                self.fallback_log_handlers.insert(position);
4085            }
4086            self.handlers.insert(position, handler);
4087        }
4088        self.next_handler_position = self.handlers.len() as u128;
4089    }
4090}
4091
4092impl<N: Network> ReactiveRuntime<N> {
4093    /// Create an empty runtime.
4094    pub fn new(config: ReactiveConfig) -> Self {
4095        Self {
4096            registry: ReactiveRegistry::new(),
4097            hooks: Vec::new(),
4098            config,
4099            journal: VecDeque::new(),
4100            coverage_head: None,
4101            pending_resyncs: Vec::new(),
4102            health: CacheHealth::Healthy,
4103            safe_head: None,
4104            finalized_head: None,
4105            metrics: CacheMetrics::default(),
4106            freshness: None,
4107            tracking: HashMap::new(),
4108            tracked_roots: HashMap::new(),
4109            root_gate_cadence: RootGateCadence::default(),
4110            last_gate_block: None,
4111            touched_since_gate: HashSet::new(),
4112            preconfirmed_branch: None,
4113        }
4114    }
4115
4116    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
4117        ReactiveRuntimeState {
4118            journal: self.journal.clone(),
4119            coverage_head: self.coverage_head,
4120            pending_resyncs: self.pending_resyncs.clone(),
4121            health: self.health,
4122            safe_head: self.safe_head,
4123            finalized_head: self.finalized_head,
4124            freshness: self.freshness.clone(),
4125            tracking: self.tracking.clone(),
4126            tracked_roots: self.tracked_roots.clone(),
4127            root_gate_cadence: self.root_gate_cadence,
4128            last_gate_block: self.last_gate_block,
4129            touched_since_gate: self.touched_since_gate.clone(),
4130            metrics: self.metrics.snapshot(),
4131        }
4132    }
4133
4134    fn is_pristine_for_checkpoint_restore(&self) -> bool {
4135        self.preconfirmed_branch.is_none()
4136            && self.journal.is_empty()
4137            && self.coverage_head.is_none()
4138            && self.pending_resyncs.is_empty()
4139            && self.health == CacheHealth::Healthy
4140            && self.safe_head.is_none()
4141            && self.finalized_head.is_none()
4142            && self.tracked_roots.is_empty()
4143            && self.last_gate_block.is_none()
4144            && self.touched_since_gate.is_empty()
4145            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
4146    }
4147
4148    fn adopted_baseline_only(&self) -> Option<BlockRef> {
4149        let baseline = self.coverage_head?;
4150        let journal_is_baseline_only = if self.config.journal_depth == 0 {
4151            self.journal.is_empty()
4152        } else {
4153            self.journal.len() == 1
4154                && self.journal.front().is_some_and(|entry| {
4155                    entry.block == baseline
4156                        && entry.inputs.is_empty()
4157                        && entry.applied.is_empty()
4158                        && entry.handler_ids.is_empty()
4159                        && entry.resynced.is_empty()
4160                        && entry.rollback_diffs.is_empty()
4161                })
4162        };
4163        (self.preconfirmed_branch.is_none()
4164            && journal_is_baseline_only
4165            && self.pending_resyncs.is_empty()
4166            && self.health == CacheHealth::Healthy
4167            && self.safe_head.is_none()
4168            && self.finalized_head.is_none()
4169            && self.tracked_roots.is_empty()
4170            && self.last_gate_block.is_none()
4171            && self.touched_since_gate.is_empty()
4172            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
4173        .then_some(baseline)
4174    }
4175
4176    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
4177        self.journal = state.journal;
4178        self.coverage_head = state.coverage_head;
4179        self.pending_resyncs = state.pending_resyncs;
4180        self.health = state.health;
4181        self.safe_head = state.safe_head;
4182        self.finalized_head = state.finalized_head;
4183        self.freshness = state.freshness;
4184        self.tracking = state.tracking;
4185        self.tracked_roots = state.tracked_roots;
4186        self.root_gate_cadence = state.root_gate_cadence;
4187        self.last_gate_block = state.last_gate_block;
4188        self.touched_since_gate = state.touched_since_gate;
4189        self.metrics.restore(state.metrics);
4190    }
4191
4192    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
4193        // Metrics describe lifetime observations, including rejected attempts,
4194        // and are documented as monotonic. Roll back canonical/runtime state
4195        // without erasing the failure signal that caused the transaction to
4196        // abort.
4197        let metrics = self.metrics.snapshot();
4198        self.restore_state(state);
4199        self.metrics.restore(metrics);
4200    }
4201
4202    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
4203        let checkpoint = DurableRuntimeCheckpoint {
4204            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
4205            safe_head: self.safe_head,
4206            finalized_head: self.finalized_head,
4207            health: self.health,
4208            pending_resyncs: self.pending_resyncs.clone(),
4209            coverage_head: self.coverage_head,
4210            journal: self
4211                .journal
4212                .iter()
4213                .map(|entry| DurableBlockJournal {
4214                    block: entry.block,
4215                    handler_ids: entry.handler_ids.clone(),
4216                    rollback_diffs: entry.rollback_diffs.clone(),
4217                })
4218                .collect(),
4219            freshness: self.freshness.clone(),
4220            tracking: self.tracking.clone(),
4221            tracked_roots: self.tracked_roots.clone(),
4222            root_gate_cadence: self.root_gate_cadence,
4223            last_gate_block: self.last_gate_block,
4224            touched_since_gate: self.touched_since_gate.clone(),
4225            metrics: self.metrics.snapshot(),
4226        };
4227        bincode::serialize(&checkpoint)
4228            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
4229    }
4230
4231    fn plan_durable_checkpoint_restore(
4232        &self,
4233        bytes: &[u8],
4234        expected_coverage: &BlockRef,
4235    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
4236        let mut cursor = std::io::Cursor::new(bytes);
4237        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
4238            .with_fixint_encoding()
4239            .with_limit(bytes.len() as u64)
4240            .deserialize_from(&mut cursor)
4241            .map_err(|error| {
4242                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
4243            })?;
4244        if cursor.position() != bytes.len() as u64 {
4245            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4246                "runtime checkpoint has trailing bytes".to_owned(),
4247            ));
4248        }
4249        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
4250            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4251                format!(
4252                    "unsupported runtime checkpoint version {}",
4253                    checkpoint.version
4254                ),
4255            ));
4256        }
4257        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
4258
4259        let retained = self.config.journal_depth.min(checkpoint.journal.len());
4260        let discard = checkpoint.journal.len() - retained;
4261        checkpoint.journal.drain(..discard);
4262        Ok(DurableRuntimeRestorePlan {
4263            checkpoint: Some(checkpoint),
4264            fallback_history: Vec::new(),
4265        })
4266    }
4267
4268    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
4269        let Some(checkpoint) = plan.checkpoint else {
4270            self.journal = plan
4271                .fallback_history
4272                .into_iter()
4273                .map(|block| BlockJournal {
4274                    block,
4275                    inputs: Vec::new(),
4276                    applied: Vec::new(),
4277                    handler_ids: Vec::new(),
4278                    resynced: Vec::new(),
4279                    rollback_diffs: Vec::new(),
4280                })
4281                .collect();
4282            return;
4283        };
4284        self.safe_head = checkpoint.safe_head;
4285        self.finalized_head = checkpoint.finalized_head;
4286        self.health = checkpoint.health;
4287        self.pending_resyncs = checkpoint.pending_resyncs;
4288        self.coverage_head = checkpoint.coverage_head;
4289        self.journal = checkpoint
4290            .journal
4291            .into_iter()
4292            .map(|entry| BlockJournal {
4293                block: entry.block,
4294                inputs: Vec::new(),
4295                applied: Vec::new(),
4296                handler_ids: entry.handler_ids,
4297                resynced: Vec::new(),
4298                rollback_diffs: entry.rollback_diffs,
4299            })
4300            .collect();
4301        self.freshness = checkpoint.freshness;
4302        self.tracking = checkpoint.tracking;
4303        self.tracked_roots = checkpoint.tracked_roots;
4304        self.root_gate_cadence = checkpoint.root_gate_cadence;
4305        self.last_gate_block = checkpoint.last_gate_block;
4306        self.touched_since_gate = checkpoint.touched_since_gate;
4307        self.metrics.restore(checkpoint.metrics);
4308    }
4309
4310    fn validate_durable_runtime_checkpoint(
4311        &self,
4312        checkpoint: &DurableRuntimeCheckpoint,
4313        expected_coverage: &BlockRef,
4314    ) -> Result<(), ReactiveCheckpointRestoreError> {
4315        let invalid =
4316            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
4317        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
4318            return Err(invalid(
4319                "runtime checkpoint is missing its canonical coverage head".into(),
4320            ));
4321        };
4322        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
4323            return Err(invalid(format!(
4324                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
4325                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
4326            )));
4327        }
4328        for (label, head) in [
4329            ("safe", checkpoint.safe_head.as_ref()),
4330            ("finalized", checkpoint.finalized_head.as_ref()),
4331        ] {
4332            let Some(head) = head else { continue };
4333            if head.number > coverage.number
4334                || (head.number == coverage.number && head.hash != coverage.hash)
4335            {
4336                return Err(invalid(format!(
4337                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
4338                    head.number, head.hash, coverage.number, coverage.hash
4339                )));
4340            }
4341            if head.number.checked_add(1) == Some(coverage.number)
4342                && coverage
4343                    .parent_hash
4344                    .is_some_and(|parent| parent != head.hash)
4345            {
4346                return Err(invalid(format!(
4347                    "canonical coverage does not descend from adjacent {label} head"
4348                )));
4349            }
4350        }
4351        if let (Some(finalized), Some(safe)) = (
4352            checkpoint.finalized_head.as_ref(),
4353            checkpoint.safe_head.as_ref(),
4354        ) {
4355            if finalized.number > safe.number
4356                || (finalized.number == safe.number && finalized.hash != safe.hash)
4357            {
4358                return Err(invalid(
4359                    "finalized head is above or conflicts with the safe head".into(),
4360                ));
4361            }
4362            if finalized.number.checked_add(1) == Some(safe.number)
4363                && safe.parent_hash != Some(finalized.hash)
4364            {
4365                return Err(invalid(
4366                    "adjacent safe head does not descend from finalized head".into(),
4367                ));
4368            }
4369        }
4370
4371        let mut previous: Option<&DurableBlockJournal> = None;
4372        for entry in &checkpoint.journal {
4373            if entry.block.number > coverage.number
4374                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
4375            {
4376                return Err(invalid(format!(
4377                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
4378                    entry.block.number, entry.block.hash
4379                )));
4380            }
4381            if let Some(previous) = previous {
4382                if entry.block.number <= previous.block.number {
4383                    return Err(invalid(
4384                        "runtime journal block numbers are not strictly increasing".into(),
4385                    ));
4386                }
4387                if previous.block.number.checked_add(1) == Some(entry.block.number)
4388                    && entry.block.parent_hash.is_some()
4389                    && entry.block.parent_hash != Some(previous.block.hash)
4390                {
4391                    return Err(invalid(
4392                        "adjacent runtime journal blocks are not parent-linked".into(),
4393                    ));
4394                }
4395            }
4396            for (label, head) in [
4397                ("safe", checkpoint.safe_head.as_ref()),
4398                ("finalized", checkpoint.finalized_head.as_ref()),
4399            ] {
4400                if let Some(head) = head
4401                    && head.number == entry.block.number
4402                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
4403                {
4404                    return Err(invalid(format!(
4405                        "{label} head conflicts with the retained journal at block {}",
4406                        head.number
4407                    )));
4408                }
4409            }
4410            let mut handler_ids = HashSet::new();
4411            if entry
4412                .handler_ids
4413                .iter()
4414                .any(|handler_id| !handler_ids.insert(handler_id))
4415            {
4416                return Err(invalid(
4417                    "runtime journal contains duplicate handler generation ids".into(),
4418                ));
4419            }
4420            previous = Some(entry);
4421        }
4422        if let Some(tail) = checkpoint.journal.last()
4423            && tail.block.number == coverage.number
4424            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4425        {
4426            return Err(invalid(format!(
4427                "runtime journal tail conflicts with canonical coverage at block {}",
4428                coverage.number
4429            )));
4430        }
4431        if let Some(tail) = checkpoint.journal.last()
4432            && tail.block.number.checked_add(1) == Some(coverage.number)
4433            && coverage
4434                .parent_hash
4435                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4436        {
4437            return Err(invalid(format!(
4438                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4439                tail.block.number
4440            )));
4441        }
4442
4443        if let Some(last_gate_block) = checkpoint.last_gate_block {
4444            if last_gate_block > coverage.number {
4445                return Err(invalid(
4446                    "root-gate cursor lies beyond canonical coverage".into(),
4447                ));
4448            }
4449        } else if !checkpoint.tracked_roots.is_empty() {
4450            return Err(invalid(
4451                "root-gate baselines exist without a completed gate cursor".into(),
4452            ));
4453        }
4454        for (address, baseline) in &checkpoint.tracked_roots {
4455            let Some(policy) = checkpoint.tracking.get(address) else {
4456                return Err(invalid(
4457                    "root-gate baseline has no corresponding tracking policy".into(),
4458                ));
4459            };
4460            if matches!(policy, TrackingPolicy::Slots { .. }) {
4461                return Err(invalid(
4462                    "slot-only tracking cannot carry an account root baseline".into(),
4463                ));
4464            }
4465            if baseline.last_block > coverage.number
4466                || checkpoint
4467                    .last_gate_block
4468                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4469            {
4470                return Err(invalid(
4471                    "root-gate baseline lies beyond the committed gate window".into(),
4472                ));
4473            }
4474        }
4475        Ok(())
4476    }
4477
4478    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4479    ///
4480    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4481    /// gate as a no-op. Registering an account clears any baseline it held (a
4482    /// policy change re-adopts on the next probe rather than diffing against a
4483    /// baseline captured under the old policy). Each [`RootGateCadence`]
4484    /// firing, the gate
4485    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4486    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4487    /// account-proof seam and, on a move no decoder covered, emits a
4488    /// [`ReactiveReport::CoverageGap`] and schedules a
4489    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4490    /// accounts are never root-gated (spec Decision 3).
4491    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4492        self.tracking.insert(address, policy);
4493        self.tracked_roots.remove(&address);
4494    }
4495
4496    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4497    ///
4498    /// Returns `true` if the account was tracked.
4499    pub fn untrack_account(&mut self, address: Address) -> bool {
4500        self.tracked_roots.remove(&address);
4501        self.tracking.remove(&address).is_some()
4502    }
4503
4504    /// Set how often the root gate probes tracked accounts (default:
4505    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4506    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4507    ///
4508    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4509    /// accumulator and the last-fired block), so a stale window never leaks
4510    /// into the new cadence: the next canonical block fires the gate.
4511    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4512        self.root_gate_cadence = cadence;
4513        self.last_gate_block = None;
4514        self.touched_since_gate.clear();
4515    }
4516
4517    /// The configured [`RootGateCadence`].
4518    pub fn root_gate_cadence(&self) -> RootGateCadence {
4519        self.root_gate_cadence
4520    }
4521
4522    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4523    ///
4524    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4525    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4526    /// touched `(address, slot)` as
4527    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4528    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4529    /// re-verify) but ages to volatile once the clock passes `N`.
4530    ///
4531    /// Idempotent: if a registry is already installed it is left untouched, so an
4532    /// existing registry (and any stamps it holds) is never clobbered.
4533    pub fn enable_freshness_stamping(&mut self) {
4534        if self.freshness.is_none() {
4535            self.freshness = Some(FreshnessRegistry::new());
4536        }
4537    }
4538
4539    /// Borrow the runtime's freshness registry, if stamping was enabled.
4540    ///
4541    /// Returns `None` unless
4542    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4543    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4544        self.freshness.as_ref()
4545    }
4546
4547    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4548    ///
4549    /// Returns `None` unless
4550    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4551    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4552        self.freshness.as_mut()
4553    }
4554
4555    /// Return the current queryable [`CacheHealth`] of the runtime.
4556    pub fn health(&self) -> CacheHealth {
4557        self.health
4558    }
4559
4560    /// Return a point-in-time snapshot of the runtime's observability counters.
4561    pub fn metrics(&self) -> CacheMetricsSnapshot {
4562        self.metrics.snapshot()
4563    }
4564
4565    /// Complete the caller-driven self-heal by returning health to
4566    /// [`CacheHealth::Healthy`].
4567    ///
4568    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4569    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4570    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4571    /// has resynced or rebuilt the affected state, it invokes this to clear the
4572    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4573    /// called outside an ingest cycle.
4574    pub fn reset_health(&mut self) {
4575        self.health = CacheHealth::Healthy;
4576    }
4577
4578    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4579    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4580    /// state actually changes.
4581    ///
4582    /// The ladder is:
4583    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4584    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4585    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4586    ///
4587    /// A first event degrades; a second escalates to the terminal
4588    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4589    /// trust-loss paths (deep reorg beyond the journal and missed-range
4590    /// detection) so mixed event types climb the same ladder.
4591    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4592        let to = match self.health {
4593            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4594            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4595            CacheHealth::Unhealthy { .. } => return None,
4596        };
4597        self.transition_health(to, Some(block))
4598    }
4599
4600    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4601    /// when the state actually changes.
4602    ///
4603    /// The returned report must be threaded into the ingest cycle's dispatched
4604    /// reports so it reaches hooks and appears in
4605    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4606    /// current state (no transition, no report).
4607    fn transition_health(
4608        &mut self,
4609        to: CacheHealth,
4610        block: Option<u64>,
4611    ) -> Option<Arc<ReactiveReport<N>>> {
4612        if to == self.health {
4613            return None;
4614        }
4615        let from = self.health;
4616        self.health = to;
4617        Some(Arc::new(ReactiveReport::Health(HealthReport {
4618            from,
4619            to,
4620            block,
4621            _network: PhantomData,
4622        })))
4623    }
4624
4625    /// Register a handler.
4626    ///
4627    /// # Errors
4628    ///
4629    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4630    /// registered.
4631    pub fn register_handler(
4632        &mut self,
4633        handler: Arc<dyn ReactiveHandler<N>>,
4634    ) -> Result<(), RegisterError> {
4635        self.registry.register_handler(handler)
4636    }
4637
4638    /// Remove one handler from the runtime registry without resetting runtime state.
4639    ///
4640    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4641    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4642    /// tracking policy, freshness registry, or root-gate baselines, and it does
4643    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4644    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4645    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4646        self.registry.unregister_handler(id)
4647    }
4648
4649    /// Return true when the runtime has a registered handler with `id`.
4650    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4651        self.registry.contains_handler(id)
4652    }
4653
4654    /// Ids of all registered handlers, in registration (= routing) order.
4655    pub fn handler_ids(&self) -> Vec<HandlerId> {
4656        self.registry.handler_ids()
4657    }
4658
4659    /// Borrow the interests owned by one registered handler.
4660    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4661        self.registry.handler_interests(id)
4662    }
4663
4664    /// The most recently journaled canonical block, if any.
4665    ///
4666    /// This is the runtime's current chain position: the canonical block most
4667    /// recently recorded by ingestion. Reorged blocks are dropped from the
4668    /// journal during recovery, so a rolled-back head does not linger here.
4669    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4670    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4671    /// advance this coverage position across an empty event range. `None` until
4672    /// the first canonical input or barrier is accepted.
4673    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4674        self.coverage_head
4675    }
4676
4677    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4678    /// position without applying effects or dispatching reports.
4679    ///
4680    /// Handlers, hooks, tracking policy, and freshness configuration may be
4681    /// installed before adoption, but no chain input, finality, resync,
4682    /// root-gate observation, or health transition may have occurred. An exact
4683    /// repeat is idempotent; a different repeat and any active runtime fail
4684    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4685    /// and subscriber are available so chain identity and the cache's exact
4686    /// hash pin are validated too.
4687    ///
4688    /// # Errors
4689    ///
4690    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4691    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4692    /// different baseline has already been adopted.
4693    pub fn adopt_canonical_baseline(
4694        &mut self,
4695        baseline: BlockRef,
4696    ) -> Result<(), ReactiveBaselineError> {
4697        self.validate_canonical_baseline_adoption(baseline)?;
4698        if self.adopted_baseline_only().is_some() {
4699            return Ok(());
4700        }
4701
4702        self.coverage_head = Some(baseline);
4703        if self.config.journal_depth > 0 {
4704            self.journal.push_back(BlockJournal {
4705                block: baseline,
4706                inputs: Vec::new(),
4707                applied: Vec::new(),
4708                handler_ids: Vec::new(),
4709                resynced: Vec::new(),
4710                rollback_diffs: Vec::new(),
4711            });
4712        }
4713        Ok(())
4714    }
4715
4716    fn validate_canonical_baseline_adoption(
4717        &self,
4718        baseline: BlockRef,
4719    ) -> Result<(), ReactiveBaselineError> {
4720        if let Some(existing) = self.adopted_baseline_only() {
4721            return if existing == baseline {
4722                Ok(())
4723            } else {
4724                Err(ReactiveBaselineError::ConflictingBaseline {
4725                    existing_number: existing.number,
4726                    existing_hash: existing.hash,
4727                    requested_number: baseline.number,
4728                    requested_hash: baseline.hash,
4729                })
4730            };
4731        }
4732        if !self.is_pristine_for_checkpoint_restore() {
4733            return Err(ReactiveBaselineError::ActiveRuntime);
4734        }
4735        Ok(())
4736    }
4737
4738    /// Most recent safe head explicitly reported by the event source.
4739    pub const fn safe_head(&self) -> Option<&BlockRef> {
4740        self.safe_head.as_ref()
4741    }
4742
4743    /// Most recent finalized head explicitly reported by the event source.
4744    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4745        self.finalized_head.as_ref()
4746    }
4747
4748    /// Return whether the retained reorg journal still contains an applied
4749    /// record for `handler_id`.
4750    ///
4751    /// The record is retained even when the handler emitted only resync work,
4752    /// so an owner can keep an explicit cache-eviction fence active for exactly
4753    /// as long as a later rollback could restore effects from that handler
4754    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4755    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4756        self.journal
4757            .iter()
4758            .any(|entry| entry.handler_ids.contains(handler_id))
4759    }
4760
4761    /// Return the distinct handler generations represented in the retained
4762    /// reorg journal.
4763    ///
4764    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4765    /// large set of cache-eviction fences without rescanning the journal for
4766    /// every handler.
4767    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4768        self.journal
4769            .iter()
4770            .flat_map(|entry| entry.handler_ids.iter().cloned())
4771            .collect()
4772    }
4773
4774    /// Queued resync requests: surfaced by handlers but not yet executed by an
4775    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4776    ///
4777    /// Callers driving resync execution themselves (plain
4778    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4779    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4780    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4781    /// generation-owned work, while
4782    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4783    /// for exclusively torn-down accounts.
4784    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4785        &self.pending_resyncs
4786    }
4787
4788    /// Cancel every queued request with the exact logical `id`.
4789    ///
4790    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4791    /// removes whole requests and never touches other work merely because it
4792    /// targets the same account. It is therefore the safe primitive for
4793    /// generation-scoped owner teardown when the caller maintains an
4794    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4795    /// an earlier batch report cannot be recalled.
4796    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4797        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4798    }
4799
4800    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4801    ///
4802    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4803    /// pending-queue order, independent of caller id order. This is the batch
4804    /// teardown primitive for owners that can have many pending repairs; it
4805    /// avoids rescanning the complete pending queue once per owned id.
4806    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4807        if ids.is_empty() {
4808            return Vec::new();
4809        }
4810        let ids: HashSet<&ResyncId> = ids.iter().collect();
4811        let mut cancelled = Vec::new();
4812        self.pending_resyncs.retain(|request| {
4813            if ids.contains(&request.id) {
4814                cancelled.push(request.clone());
4815                false
4816            } else {
4817                true
4818            }
4819        });
4820        cancelled
4821    }
4822
4823    /// Cancel queued resync work that targets `address`, returning the
4824    /// cancelled portions.
4825    ///
4826    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4827    /// a request reduced to zero targets is dropped entirely, while
4828    /// mixed-target requests keep their other accounts queued. Each returned
4829    /// request mirrors the original id/reason/block/priority and carries only
4830    /// the targets that were cancelled.
4831    ///
4832    /// This is appropriate only when the caller owns the complete account. For
4833    /// a pool sharing a vault or emitter with other owners, cancel its exact
4834    /// request IDs through
4835    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4836    /// recall requests already returned to the caller in earlier batch reports.
4837    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4838        let mut cancelled = Vec::new();
4839        self.pending_resyncs.retain_mut(|request| {
4840            let (matching, remaining): (Vec<_>, Vec<_>) = request
4841                .targets
4842                .drain(..)
4843                .partition(|target| resync_target_address(target) == address);
4844            request.targets = remaining;
4845            if !matching.is_empty() {
4846                cancelled.push(ResyncRequest {
4847                    id: request.id.clone(),
4848                    reason: request.reason.clone(),
4849                    block: request.block.clone(),
4850                    targets: matching,
4851                    priority: request.priority,
4852                });
4853            }
4854            !request.targets.is_empty()
4855        });
4856        cancelled
4857    }
4858
4859    /// Register a hook.
4860    ///
4861    /// # Errors
4862    ///
4863    /// This implementation is currently infallible; the `Result` preserves the
4864    /// registration contract for future hook validation.
4865    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4866        self.hooks.push(hook);
4867        Ok(())
4868    }
4869
4870    /// Return all registered interests in handler registration order.
4871    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4872        self.registry.interests()
4873    }
4874
4875    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4876    ///
4877    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4878    /// restored before the error returns, and hooks see no reports. Monotonic
4879    /// observability counters still retain rejected-attempt signals.
4880    /// The current rollback guard snapshots complete mutable cache state once per
4881    /// batch, so callers should preserve transport batching rather than splitting
4882    /// one delivery into many one-record calls.
4883    ///
4884    /// # Errors
4885    ///
4886    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4887    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4888    /// be applied. A pre-confirmed batch additionally requires an adopted
4889    /// canonical coverage head and must identify its exact child by number and
4890    /// parent hash. Cache and canonical runtime state are restored before
4891    /// return; a lineage failure revokes any active speculative branch.
4892    pub fn ingest_batch(
4893        &mut self,
4894        cache: &mut EvmCache,
4895        batch: ReactiveInputBatch<N>,
4896    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4897        let preconfirmation = batch_preconfirmation(&batch)?;
4898        if let Some(flashblock) = preconfirmation.as_ref() {
4899            self.prepare_preconfirmed_branch(cache, flashblock)?;
4900        } else {
4901            self.discard_preconfirmed_branch(cache);
4902        }
4903        let cache_state = EvmCacheStateSnapshot::capture(cache);
4904        let runtime_state = self.checkpoint_state();
4905        let batch_report = match self.ingest_batch_direct(cache, batch) {
4906            Ok(report) => report,
4907            Err(error) => {
4908                cache_state.restore(cache);
4909                self.restore_transaction_state(runtime_state);
4910                return Err(error);
4911            }
4912        };
4913        if let Some(flashblock) = preconfirmation {
4914            self.restore_transaction_state(runtime_state);
4915            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4916                branch.flashblock = flashblock;
4917            }
4918        }
4919        self.dispatch_reports(&batch_report.reports);
4920        let _ = &self.config;
4921        Ok(batch_report)
4922    }
4923
4924    /// Ingest a batch, then execute surfaced storage resync requests.
4925    ///
4926    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
4927    /// direct handler effects, then runs a synchronous resync phase over the
4928    /// collected [`ResyncRequest`]s. Storage targets are fetched through
4929    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
4930    /// values are applied as [`StateUpdate::slot`] updates through
4931    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
4932    /// in [`ResyncReport::failed`]. It does not start subscribers, background
4933    /// workers, or network transport.
4934    ///
4935    /// # Errors
4936    ///
4937    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
4938    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
4939    /// resync targets are reported in the successful batch report instead.
4940    pub fn ingest_batch_with_resync(
4941        &mut self,
4942        cache: &mut EvmCache,
4943        batch: ReactiveInputBatch<N>,
4944    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4945        let preconfirmation = batch_preconfirmation(&batch)?;
4946        if let Some(flashblock) = preconfirmation.as_ref() {
4947            self.prepare_preconfirmed_branch(cache, flashblock)?;
4948        } else {
4949            self.discard_preconfirmed_branch(cache);
4950        }
4951        let cache_state = EvmCacheStateSnapshot::capture(cache);
4952        let runtime_state = self.checkpoint_state();
4953        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
4954            Ok(report) => report,
4955            Err(error) => {
4956                cache_state.restore(cache);
4957                self.restore_transaction_state(runtime_state);
4958                return Err(error);
4959            }
4960        };
4961
4962        if let Some(flashblock) = preconfirmation {
4963            self.restore_transaction_state(runtime_state);
4964            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4965                branch.flashblock = flashblock;
4966            }
4967        }
4968
4969        self.dispatch_reports(&batch_report.reports);
4970        let _ = &self.config;
4971        Ok(batch_report)
4972    }
4973
4974    /// Active speculative Flashblock snapshot, when the cache currently
4975    /// includes pre-confirmed effects.
4976    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
4977        self.preconfirmed_branch
4978            .as_ref()
4979            .map(|branch| &branch.flashblock)
4980    }
4981
4982    /// Restore the cache to its canonical state and discard any speculative
4983    /// Flashblock effects.
4984    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
4985        self.discard_preconfirmed_branch(cache);
4986    }
4987
4988    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
4989        if let Some(branch) = self.preconfirmed_branch.take() {
4990            branch.canonical_cache.restore(cache);
4991        }
4992    }
4993
4994    fn prepare_preconfirmed_branch(
4995        &mut self,
4996        cache: &mut EvmCache,
4997        incoming: &FlashblockRef,
4998    ) -> Result<(), ReactiveError> {
4999        let Some(canonical) = self.coverage_head else {
5000            self.discard_preconfirmed_branch(cache);
5001            return Err(ReactiveError::InvalidInputRecord {
5002                message: "pre-confirmed state requires an exact canonical coverage baseline".into(),
5003            });
5004        };
5005        if canonical.number.checked_add(1) != Some(incoming.block_number) {
5006            self.discard_preconfirmed_branch(cache);
5007            return Err(ReactiveError::InvalidInputRecord {
5008                message: format!(
5009                    "pre-confirmed block {} is not the exact successor of canonical block {}",
5010                    incoming.block_number, canonical.number
5011                ),
5012            });
5013        }
5014        if incoming.parent_hash != Some(canonical.hash) {
5015            self.discard_preconfirmed_branch(cache);
5016            return Err(ReactiveError::InvalidInputRecord {
5017                message: "pre-confirmed block parent does not match the canonical coverage hash"
5018                    .into(),
5019            });
5020        }
5021        if let Some(active) = self.preconfirmed_branch.as_ref()
5022            && active.flashblock.same_payload(incoming)
5023        {
5024            if let (Some(active_index), Some(incoming_index)) =
5025                (active.flashblock.index, incoming.index)
5026                && incoming_index < active_index
5027            {
5028                return Err(ReactiveError::InvalidInputRecord {
5029                    message: format!(
5030                        "Flashblock index regressed from {active_index} to {incoming_index}"
5031                    ),
5032                });
5033            }
5034            if active.flashblock.index.is_some()
5035                && active.flashblock.index == incoming.index
5036                && active.flashblock.content_hash != incoming.content_hash
5037            {
5038                self.discard_preconfirmed_branch(cache);
5039                return Err(ReactiveError::InvalidInputRecord {
5040                    message: "same Flashblock payload/index carried conflicting cumulative content"
5041                        .into(),
5042                });
5043            }
5044            install_preconfirmed_cache_context(cache, incoming);
5045            return Ok(());
5046        }
5047
5048        self.discard_preconfirmed_branch(cache);
5049        self.preconfirmed_branch = Some(PreconfirmedBranch {
5050            flashblock: incoming.clone(),
5051            canonical_cache: EvmCacheStateSnapshot::capture(cache),
5052        });
5053        install_preconfirmed_cache_context(cache, incoming);
5054        Ok(())
5055    }
5056
5057    fn ingest_batch_with_resync_direct(
5058        &mut self,
5059        cache: &mut EvmCache,
5060        batch: ReactiveInputBatch<N>,
5061    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5062        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
5063        if !batch_report.resyncs.is_empty() {
5064            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
5065            // Count unique logical requests: several handlers may emit the same
5066            // ResyncId in one batch, and duplicates fan out per-origin in the
5067            // report but are one unit of resync work for the metric.
5068            let unique_requests = resync_report
5069                .requested
5070                .iter()
5071                .map(|request| &request.id)
5072                .collect::<HashSet<_>>()
5073                .len();
5074            self.metrics
5075                .resync_requests
5076                .fetch_add(unique_requests as u64, Ordering::Relaxed);
5077            self.metrics
5078                .resync_failures
5079                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
5080            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
5081            self.record_journal_resync(&resync_report);
5082            batch_report
5083                .reports
5084                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
5085        }
5086        Ok(batch_report)
5087    }
5088
5089    fn ingest_batch_direct(
5090        &mut self,
5091        cache: &mut EvmCache,
5092        batch: ReactiveInputBatch<N>,
5093    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5094        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
5095        if let Some(chain_id) = batch_chain_id
5096            && chain_id != cache.chain_id()
5097        {
5098            return Err(ReactiveError::InvalidInputRecord {
5099                message: format!(
5100                    "batch chain id {chain_id} does not match cache chain id {}",
5101                    cache.chain_id()
5102                ),
5103            });
5104        }
5105        if !chain_controls.is_empty() && batch_chain_id.is_none() {
5106            return Err(ReactiveError::InvalidChainControl {
5107                message: "chain-control batches require an authoritative batch chain id".into(),
5108            });
5109        }
5110        for (record, _, _) in &records {
5111            record.validated_identity()?;
5112            if let Some(chain_id) = record.context.chain_id
5113                && chain_id != cache.chain_id()
5114            {
5115                return Err(ReactiveError::InvalidInputRecord {
5116                    message: format!(
5117                        "input chain id {chain_id} does not match cache chain id {}",
5118                        cache.chain_id()
5119                    ),
5120                });
5121            }
5122        }
5123        let records = sort_scoped_records(dedupe_scoped_records(records)?);
5124
5125        let mut batch_report = ReactiveBatchReport::default();
5126        let mut reports_to_dispatch = Vec::new();
5127        let control_split = validate_control_phase_order(&chain_controls)?;
5128        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
5129        let pre_record_state =
5130            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
5131        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
5132        let mut batch_dropped = BatchDroppedCanonical::default();
5133        for control in pre_record_controls {
5134            if let ChainControl::Reorg {
5135                common_ancestor,
5136                old_tip,
5137                ..
5138            } = control
5139            {
5140                batch_dropped.record_explicit(common_ancestor, old_tip);
5141                let drained = self
5142                    .journal
5143                    .iter()
5144                    .filter(|entry| entry.block.number > common_ancestor.number)
5145                    .map(|entry| entry.block)
5146                    .collect::<Vec<_>>();
5147                batch_dropped.record_drained(&drained);
5148            }
5149        }
5150        let certified_progress_through = post_record_controls
5151            .iter()
5152            .filter_map(canonical_coverage_control_block)
5153            .map(|block| block.number)
5154            .max();
5155        for control in pre_record_controls.iter().cloned() {
5156            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5157        }
5158        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
5159        // batch (union of applied `StateDiff` addresses) and the batch's canonical
5160        // block number, so the per-block root gate can run once after the record
5161        // loop with the full touched set.
5162        let mut touched_addrs: HashSet<Address> = HashSet::new();
5163        let mut canonical_batch_block: Option<u64> = None;
5164
5165        for (record, audience, delivery_scope) in records {
5166            let raw_canonical_block = canonical_record_block(&record).copied();
5167            let canonical_block = raw_canonical_block.map(|block| {
5168                pre_record_state
5169                    .resolved_canonical_blocks
5170                    .get(&(block.number, block.hash))
5171                    .copied()
5172                    .unwrap_or(block)
5173            });
5174            let input_ref = record.input_ref();
5175            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
5176                input_ref,
5177                context: record.context.clone(),
5178                provider: record.provider.clone(),
5179                _network: PhantomData,
5180            })));
5181
5182            let recovered_reorg = if delivery_scope.advances_canonical_state() {
5183                if let Some(block) = canonical_block.as_ref() {
5184                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
5185                        && certified_progress_through
5186                            .is_some_and(|through| block.number <= through);
5187                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
5188                        raw.parent_hash.is_none()
5189                            && batch_dropped.covers_implicit_number(raw.number)
5190                    });
5191                    self.recover_for_canonical_input(
5192                        cache,
5193                        block,
5194                        gap_is_certified,
5195                        parentless_replacement_is_proven,
5196                        &mut reports_to_dispatch,
5197                    )
5198                } else {
5199                    None
5200                }
5201            } else {
5202                None
5203            };
5204            let recovered_reorg_for_input = recovered_reorg.is_some();
5205            if let Some(reorg_report) = recovered_reorg {
5206                self.metrics
5207                    .reorgs_recovered
5208                    .fetch_add(1, Ordering::Relaxed);
5209                remove_canceled_resyncs_from_batch(
5210                    &mut batch_report.resyncs,
5211                    &reorg_report.canceled_resyncs,
5212                );
5213                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5214            }
5215
5216            // Removed/reorged records are lifecycle signals, never handler
5217            // data. Canonical scopes may roll back state; owner-only catch-up
5218            // scopes deliberately cannot, but both must suppress ordinary
5219            // decoding even when the referenced block is unknown, aged out of
5220            // the journal, or has already been removed once.
5221            if reorg_signal_block(&record).is_some() {
5222                if delivery_scope.advances_canonical_state()
5223                    && let Some(reorg_report) = self.recover_for_reorged_input(
5224                        cache,
5225                        &record,
5226                        &mut batch_dropped,
5227                        &mut reports_to_dispatch,
5228                    )
5229                {
5230                    self.metrics
5231                        .reorgs_recovered
5232                        .fetch_add(1, Ordering::Relaxed);
5233                    remove_canceled_resyncs_from_batch(
5234                        &mut batch_report.resyncs,
5235                        &reorg_report.canceled_resyncs,
5236                    );
5237                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5238                }
5239                continue;
5240            }
5241
5242            // Preflight validates owner history against the journal state at
5243            // batch entry. A canonical record earlier in this same transaction
5244            // may legitimately replace and drain that block, so close the
5245            // resulting TOCTOU window immediately before any owner handler can
5246            // mutate the cache. The outer transaction guard restores every
5247            // earlier record in the batch on failure.
5248            if delivery_scope == DeliveryScope::OwnerCatchup {
5249                self.validate_owner_catchup_record_against_current_journal(&record)?;
5250            }
5251
5252            if delivery_scope.advances_canonical_state()
5253                && let Some(block) = canonical_block.as_ref()
5254            {
5255                // Phase-8 step 4: remember the batch's canonical block (the last
5256                // canonical record wins) so the root gate probes at that height.
5257                canonical_batch_block = Some(block.number);
5258                self.record_journal_input(block, input_ref);
5259            }
5260
5261            // Keep every lazy provider read pinned to the exact event block
5262            // before handlers run. A full header installs the complete EVM env;
5263            // compact log-only progress installs NUMBER/timestamp and clears
5264            // unknown header-only fields. A later record for the same retained
5265            // canonical block can preserve an already-installed full env.
5266            if delivery_scope.advances_canonical_state()
5267                && let Some(block) = canonical_block.as_ref()
5268            {
5269                match advance_block_for_canonical_record(cache, &record) {
5270                    Some(Ok(())) => {
5271                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
5272                    }
5273                    Some(Err(err)) => {
5274                        cache.advance_compact_block(
5275                            block.number,
5276                            block.hash,
5277                            block.timestamp,
5278                            false,
5279                        );
5280                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
5281                            ReactiveErrorReport {
5282                                input_ref: Some(input_ref),
5283                                message: err.to_string(),
5284                                _network: PhantomData,
5285                            },
5286                        )));
5287                    }
5288                    None => cache.advance_compact_block(
5289                        block.number,
5290                        block.hash,
5291                        block.timestamp,
5292                        !recovered_reorg_for_input,
5293                    ),
5294                }
5295            }
5296
5297            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
5298            if executions.is_empty() {
5299                continue;
5300            }
5301
5302            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
5303                input_ref,
5304                handler_ids: executions
5305                    .iter()
5306                    .map(|execution| execution.handler_id.clone())
5307                    .collect(),
5308                _network: PhantomData,
5309            })));
5310
5311            detect_conflicts(input_ref, &executions)?;
5312
5313            // Phase-8 step 3: canonical block number for freshness stamping.
5314            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
5315            // can be used while `self.freshness_mut()` mutably borrows `self`
5316            // inside the execution loop. `None` for pending/removed/reorged
5317            // records — those never stamp canonical freshness.
5318            let canonical_block_number = delivery_scope
5319                .advances_canonical_state()
5320                .then_some(canonical_block)
5321                .flatten()
5322                .map(|block| block.number);
5323
5324            for execution in executions {
5325                let diff = if execution.state_updates.is_empty() {
5326                    StateDiff::default()
5327                } else {
5328                    cache.apply_updates(&execution.state_updates)
5329                };
5330
5331                batch_report
5332                    .resyncs
5333                    .extend(execution.resyncs.iter().cloned());
5334                self.pending_resyncs
5335                    .extend(execution.resyncs.iter().cloned());
5336                batch_report
5337                    .speculative
5338                    .extend(execution.speculative.iter().cloned());
5339
5340                let applied = AppliedReport {
5341                    input_ref,
5342                    handler_id: execution.handler_id,
5343                    quality: execution.quality,
5344                    tags: execution.tags,
5345                    diff,
5346                    state_updates: execution.state_updates,
5347                    invalidations: execution.invalidations,
5348                    resyncs: execution.resyncs,
5349                    speculative: execution.speculative,
5350                    hook_signals: execution.hook_signals,
5351                    _network: PhantomData,
5352                };
5353                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
5354                // from this canonical handler write as `ValidThrough(N)`, so an
5355                // event-maintained slot stops being re-verified until the clock
5356                // passes its write block. Read the changed slots straight off
5357                // `applied.diff` (which borrows the local, not `self`) and stamp
5358                // via `self.freshness`, done before `applied` is moved into the
5359                // journal/batch below. Only genuinely-changed slots appear here,
5360                // since a no-op re-write records no `SlotChange`.
5361                if let (Some(number), Some(registry)) =
5362                    (canonical_block_number, self.freshness.as_mut())
5363                {
5364                    for change in &applied.diff.slots {
5365                        registry.valid_through_slot(change.address, change.slot, number);
5366                    }
5367                }
5368
5369                // Phase-8 step 4: record every address this decoder actually wrote
5370                // (or attempted to write) so the root gate can tell a
5371                // decoder-covered root move from an uncovered coverage gap. Fold in
5372                // the full `StateDiff` address footprint — real changes
5373                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
5374                // a decoder that tried to write a cold slot still counts as
5375                // covering the account.
5376                if delivery_scope.advances_canonical_state() {
5377                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
5378                }
5379
5380                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
5381                reports_to_dispatch.push(report);
5382                if let Some(block) = canonical_block.as_ref() {
5383                    if delivery_scope.advances_canonical_state() {
5384                        self.record_journal_applied(block, applied.clone());
5385                    } else {
5386                        self.record_journal_applied_if_present(block, applied.clone());
5387                    }
5388                }
5389                batch_report.applied.push(applied);
5390            }
5391        }
5392
5393        // Coverage/finality controls certify the records that precede them.
5394        // Applying them here also leaves the live cache pinned to a certified
5395        // zero-event tail rather than the last block that happened to emit a
5396        // matching log. Reorg controls were applied before the record loop.
5397        for control in post_record_controls.iter().cloned() {
5398            if let Some(block) = canonical_coverage_control_block(&control) {
5399                canonical_batch_block = Some(
5400                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
5401                );
5402            }
5403            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5404        }
5405
5406        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
5407        // addresses (after all handler effects, so the set is complete), then
5408        // fire the root gate only on cadence boundaries. The gate diffs
5409        // against persisted baselines, so skipped blocks lose no detection —
5410        // but the touched set must be the union since the last firing, or a
5411        // decoder-covered write in a skipped block would false-positive as a
5412        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
5413        // callers see them and `ingest_batch_with_resync` executes them) and
5414        // coverage reports go into the dispatched reports.
5415        if self.root_gate_runnable(cache) {
5416            self.touched_since_gate
5417                .extend(touched_addrs.iter().copied());
5418            if self.root_gate_due(canonical_batch_block) {
5419                let accumulated = std::mem::take(&mut self.touched_since_gate);
5420                self.run_root_gate(
5421                    cache,
5422                    canonical_batch_block,
5423                    &accumulated,
5424                    &mut batch_report.resyncs,
5425                    &mut reports_to_dispatch,
5426                );
5427                self.last_gate_block = canonical_batch_block;
5428            }
5429        } else {
5430            // A gate that cannot run (disabled, nothing root-gated, or no
5431            // proof fetcher) must not grow the accumulator unboundedly.
5432            // Dropping it is safe: without a runnable gate no baselines exist
5433            // (a fetcher cannot be uninstalled, and untracking drops the
5434            // baseline), so there is nothing a lost touched set could falsely
5435            // gap against later.
5436            self.touched_since_gate.clear();
5437        }
5438
5439        batch_report.reports = reports_to_dispatch;
5440        Ok(batch_report)
5441    }
5442
5443    /// Prove that every owner-only historical effect can be attached to an
5444    /// compatible retained canonical journal entry before any chain control or
5445    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5446    /// optional enrichment, but two present values must agree; this matches the
5447    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5448    ///
5449    /// Owner catch-up deliberately does not advance canonical coverage. Its
5450    /// effects are appended to the already-existing journal entry so a later
5451    /// reorg can roll them back with the rest of that block. Accepting a block
5452    /// outside the journal would make the cache mutation irreversible. A reorg
5453    /// control in the same batch also invalidates entries above its ancestor,
5454    /// so those entries are rejected even though they still exist at this
5455    /// preflight point.
5456    fn validate_owner_catchup_against_journal(
5457        &self,
5458        control_state: &ChainControlState,
5459        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5460    ) -> Result<(), ReactiveError> {
5461        for (record, _, delivery_scope) in records {
5462            if *delivery_scope != DeliveryScope::OwnerCatchup {
5463                continue;
5464            }
5465            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5466            // cannot make them canonical and the record loop deliberately skips
5467            // handler execution, so there is no effect that needs attaching to
5468            // a rollback journal entry.
5469            if reorg_signal_block(record).is_some() {
5470                continue;
5471            }
5472            let context_block = canonical_record_block(record).ok_or_else(|| {
5473                ReactiveError::InvalidChainControl {
5474                    message: "owner catch-up input has no canonical block identity".into(),
5475                }
5476            })?;
5477            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5478            let invalidated_by_control = control_state
5479                .journal_invalidated_from
5480                .is_some_and(|from| block.number >= from);
5481            let rollbackable = !invalidated_by_control
5482                && self.journal.iter().any(|entry| {
5483                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5484                });
5485            if !rollbackable {
5486                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5487                    number: block.number,
5488                    hash: block.hash,
5489                });
5490            }
5491        }
5492        Ok(())
5493    }
5494
5495    fn validate_owner_catchup_record_against_current_journal(
5496        &self,
5497        record: &ReactiveInputRecord<N>,
5498    ) -> Result<(), ReactiveError> {
5499        let context_block =
5500            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5501                message: "owner catch-up input has no canonical block identity".into(),
5502            })?;
5503        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5504        if self
5505            .journal
5506            .iter()
5507            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5508        {
5509            return Ok(());
5510        }
5511        Err(ReactiveError::OwnerCatchupOutsideJournal {
5512            number: block.number,
5513            hash: block.hash,
5514        })
5515    }
5516
5517    /// Whether the root gate could produce any signal at all: some tracked
5518    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5519    /// When this is false the touched accumulator is dropped rather than
5520    /// grown (see the ingest call site for why that is safe).
5521    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5522        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5523            return false;
5524        }
5525        let has_gated_targets = self
5526            .tracking
5527            .values()
5528            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5529        has_gated_targets && cache.account_proof_fetcher().is_some()
5530    }
5531
5532    /// Whether the root gate is due at this batch's canonical block (§6.2):
5533    /// the first canonical block ever seen always fires (baseline adoption
5534    /// must not wait a full window), then at most once every `n` blocks.
5535    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5536        let Some(block) = canonical_block else {
5537            return false;
5538        };
5539        match self.root_gate_cadence {
5540            RootGateCadence::Disabled => false,
5541            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5542                None => true,
5543                Some(last) => block >= last.saturating_add(n.get()),
5544            },
5545        }
5546    }
5547
5548    /// The `storageHash` root gate (Phase-8 step 4), fired per
5549    /// [`RootGateCadence`] window (§6.2).
5550    ///
5551    /// Runs at the firing batch's canonical block, with `touched` carrying the
5552    /// union of decoder-touched addresses since the previous firing. For each tracked
5553    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5554    /// account, probe the root (and account fields) via the account-proof seam and
5555    /// apply the spec §4 table:
5556    ///
5557    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5558    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5559    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5560    ///   ⇒ a decoder covered it; re-adopt, no gap.
5561    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5562    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5563    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5564    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5565    ///   the baseline (native field changes never move the storage root); on a move
5566    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5567    ///   resync for the changed fields and re-adopt.
5568    ///
5569    /// No-op when the tracking registry is empty, when the batch has no canonical
5570    /// block, or when the cache has no account-proof fetcher installed.
5571    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5572    /// Decision 3).
5573    fn run_root_gate(
5574        &mut self,
5575        cache: &EvmCache,
5576        canonical_block: Option<u64>,
5577        touched: &HashSet<Address>,
5578        resyncs: &mut Vec<ResyncRequest>,
5579        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5580    ) {
5581        if self.tracking.is_empty() {
5582            return;
5583        }
5584        let Some(block) = canonical_block else {
5585            return;
5586        };
5587        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5588            return;
5589        };
5590
5591        // Collect the root-gated targets (Slots opts out) in a stable order so a
5592        // single-block sequence of resyncs/reports is deterministic.
5593        let mut targets: Vec<(Address, bool)> = self
5594            .tracking
5595            .iter()
5596            .filter_map(|(address, policy)| match policy {
5597                TrackingPolicy::Slots { .. } => None,
5598                TrackingPolicy::WholeAccount => Some((*address, true)),
5599                TrackingPolicy::Scalars => Some((*address, false)),
5600            })
5601            .collect();
5602        if targets.is_empty() {
5603            return;
5604        }
5605        targets.sort_by_key(|(address, _)| *address);
5606
5607        let block_id = BlockId::number(block);
5608        // ONE seam invocation carries every root-gated target (root-only
5609        // probes: no storage keys needed). eth_getProof is single-address at
5610        // the RPC level, so batching here lets the fetcher fan the requests
5611        // out concurrently instead of paying N sequential round trips.
5612        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5613            targets
5614                .iter()
5615                .map(|&(address, _)| (address, vec![]))
5616                .collect(),
5617            block_id,
5618        )
5619        .into_iter()
5620        .collect();
5621        for (address, whole_account) in targets {
5622            let Some(Ok(proof)) = probes.remove(&address) else {
5623                // A failed/omitted probe carries no signal; leave the baseline
5624                // untouched and try again next block.
5625                continue;
5626            };
5627
5628            let baseline = self.tracked_roots.get(&address).cloned();
5629            let Some(baseline) = baseline else {
5630                // First observation: adopt the baseline. Not a coverage gap.
5631                self.adopt_root(address, block, &proof);
5632                continue;
5633            };
5634
5635            // A stale probe (a batch whose canonical block is not newer than the
5636            // last one we baselined this account against) carries no forward
5637            // signal: skip it rather than diff against — or clobber — a newer
5638            // baseline.
5639            if block <= baseline.last_block {
5640                continue;
5641            }
5642
5643            if whole_account {
5644                if proof.storage_hash == baseline.last_root {
5645                    // Tight steady-state path: unchanged root ⇒ nothing.
5646                    continue;
5647                }
5648                // Root moved.
5649                if !touched.contains(&address) {
5650                    // Moved with no covering decoder — the coverage gap.
5651                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5652                        address,
5653                        block,
5654                        _network: PhantomData,
5655                    })));
5656                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5657                    resyncs.push(root_moved_account_resync(
5658                        address,
5659                        block,
5660                        AccountFieldMask {
5661                            balance: true,
5662                            nonce: true,
5663                            code: true,
5664                        },
5665                    ));
5666                }
5667                // Adopt the new root whether or not a decoder covered it.
5668                self.adopt_root(address, block, &proof);
5669            } else {
5670                // Scalars: compare the account fields directly (native changes do
5671                // not move the storage root).
5672                let balance_moved = proof.balance != baseline.balance;
5673                let nonce_moved = proof.nonce != baseline.nonce;
5674                let code_moved = proof.code_hash != baseline.code_hash;
5675                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5676                    resyncs.push(root_moved_account_resync(
5677                        address,
5678                        block,
5679                        AccountFieldMask {
5680                            balance: balance_moved,
5681                            nonce: nonce_moved,
5682                            code: code_moved,
5683                        },
5684                    ));
5685                }
5686                self.adopt_root(address, block, &proof);
5687            }
5688        }
5689    }
5690
5691    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5692    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5693        self.tracked_roots.insert(
5694            address,
5695            TrackedRoot {
5696                last_root: proof.storage_hash,
5697                last_block: block,
5698                balance: proof.balance,
5699                nonce: proof.nonce,
5700                code_hash: proof.code_hash,
5701            },
5702        );
5703    }
5704
5705    fn execute_handlers(
5706        &self,
5707        cache: &EvmCache,
5708        record: &ReactiveInputRecord<N>,
5709        input_ref: InputRef,
5710        audience: &DeliveryAudience,
5711    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5712        let mut executions = Vec::new();
5713        let candidates: Vec<_> = match &record.input {
5714            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5715            ReactiveInput::BlockHeader(_)
5716            | ReactiveInput::FullBlock(_)
5717            | ReactiveInput::PendingTxHash(_)
5718            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5719        };
5720        for registered in candidates {
5721            match audience {
5722                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5723                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5724                    continue;
5725                }
5726                DeliveryAudience::All
5727                | DeliveryAudience::Owners(_)
5728                | DeliveryAudience::AllExcept(_) => {}
5729            }
5730            if !registered.matches(&record.input) {
5731                continue;
5732            }
5733
5734            let outcome = registered
5735                .handler
5736                .handle(&record.context, &record.input, cache)
5737                .map_err(|source| ReactiveError::HandlerFailed {
5738                    handler_id: registered.id.clone(),
5739                    source,
5740                })?;
5741
5742            if let Err(error) =
5743                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5744            {
5745                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5746                    self.metrics
5747                        .pending_contamination
5748                        .fetch_add(1, Ordering::Relaxed);
5749                }
5750                return Err(error);
5751            }
5752            executions.push(HandlerExecution::from_outcome(
5753                registered.id.clone(),
5754                input_ref,
5755                outcome,
5756                matches!(
5757                    record.context.chain_status,
5758                    ChainStatus::Preconfirmed { .. }
5759                ),
5760            ));
5761        }
5762        Ok(executions)
5763    }
5764
5765    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5766        for report in reports {
5767            for hook in &self.hooks {
5768                hook.on_report(report.clone());
5769            }
5770        }
5771    }
5772
5773    fn apply_chain_control(
5774        &mut self,
5775        cache: &mut EvmCache,
5776        control: ChainControl,
5777        batch_report: &mut ReactiveBatchReport<N>,
5778        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5779    ) {
5780        match &control {
5781            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5782            ChainControl::Finalized(block) => {
5783                set_or_enrich_block_ref(&mut self.finalized_head, block);
5784            }
5785            ChainControl::CanonicalProgress(block)
5786            | ChainControl::Barrier {
5787                block: Some(block), ..
5788            } => {
5789                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5790                    optional_block_refs_are_compatible(Some(current), Some(block))
5791                });
5792                cache.advance_compact_block(
5793                    block.number,
5794                    block.hash,
5795                    block.timestamp,
5796                    preserve_env,
5797                );
5798                advance_or_enrich_coverage(&mut self.coverage_head, block);
5799                let enriched = self.journal_entry_mut(block).block;
5800                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5801                self.trim_journal();
5802            }
5803            ChainControl::Barrier { block: None, .. } => {}
5804            ChainControl::Reorg {
5805                common_ancestor,
5806                old_tip,
5807                ..
5808            } => {
5809                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5810                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5811                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5812                    entry.block.number == common_ancestor.number
5813                        && entry.block.hash == common_ancestor.hash
5814                }) {
5815                    self.drain_journal_after(ancestor_index)
5816                } else {
5817                    // Sparse journals are expected for blocks with no matching
5818                    // events. If the oldest retained entry is at or below the
5819                    // ancestor, every effect above it is still present and the
5820                    // rollback is complete even without an exact anchor.
5821                    if self
5822                        .journal
5823                        .front()
5824                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5825                    {
5826                        reports.extend(
5827                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5828                        );
5829                    }
5830                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5831                };
5832
5833                let reorg_report = self
5834                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5835                    .unwrap_or_else(|| ReorgReport {
5836                        dropped: Some(*old_tip),
5837                        dropped_blocks: Vec::new(),
5838                        dropped_inputs: Vec::new(),
5839                        rollback_updates: Vec::new(),
5840                        rollback_diff: StateDiff::default(),
5841                        purge_updates: Vec::new(),
5842                        purge_diff: StateDiff::default(),
5843                        canceled_resyncs: self
5844                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5845                        reason: ReorgReason::Explicit,
5846                        _network: PhantomData,
5847                    });
5848                remove_canceled_resyncs_from_batch(
5849                    &mut batch_report.resyncs,
5850                    &reorg_report.canceled_resyncs,
5851                );
5852                self.metrics
5853                    .reorgs_recovered
5854                    .fetch_add(1, Ordering::Relaxed);
5855                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5856
5857                if self.safe_head.as_ref().is_some_and(|head| {
5858                    head.number > common_ancestor.number
5859                        || (head.number == common_ancestor.number
5860                            && head.hash != common_ancestor.hash)
5861                }) {
5862                    self.safe_head = None;
5863                }
5864                if self.finalized_head.as_ref().is_some_and(|head| {
5865                    head.number > common_ancestor.number
5866                        || (head.number == common_ancestor.number
5867                            && head.hash != common_ancestor.hash)
5868                }) {
5869                    self.finalized_head = None;
5870                }
5871                let mut enriched_ancestor = *common_ancestor;
5872                if let Some(entry) = self.journal.iter().find(|entry| {
5873                    entry.block.number == common_ancestor.number
5874                        && entry.block.hash == common_ancestor.hash
5875                }) {
5876                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5877                }
5878                if let Some(current) = self.coverage_head.as_ref()
5879                    && current.number == common_ancestor.number
5880                    && current.hash == common_ancestor.hash
5881                {
5882                    enrich_block_ref(&mut enriched_ancestor, current);
5883                }
5884                self.coverage_head = Some(enriched_ancestor);
5885                cache.advance_compact_block(
5886                    enriched_ancestor.number,
5887                    enriched_ancestor.hash,
5888                    enriched_ancestor.timestamp,
5889                    false,
5890                );
5891                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5892                self.coverage_head = Some(enriched_ancestor);
5893                self.trim_journal();
5894            }
5895        }
5896        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5897            control,
5898        })));
5899    }
5900
5901    fn validate_ingest_sequence(
5902        &self,
5903        pre_record_controls: &[ChainControl],
5904        post_record_controls: &[ChainControl],
5905        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5906    ) -> Result<ChainControlState, ReactiveError> {
5907        let mut controls =
5908            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
5909        controls.extend_from_slice(pre_record_controls);
5910        controls.extend_from_slice(post_record_controls);
5911        let state = CanonicalSequenceState::new(
5912            self.journal.iter().map(|entry| entry.block).collect(),
5913            self.coverage_head,
5914            self.safe_head,
5915            self.finalized_head,
5916        );
5917        let record_metadata = records
5918            .iter()
5919            .map(|(record, _, scope)| (record, *scope))
5920            .collect::<Vec<_>>();
5921        let validation = validate_canonical_sequence_parts(
5922            &state,
5923            &controls,
5924            &record_metadata,
5925            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
5926        )
5927        .map_err(CanonicalSequenceError::into_reactive_error)?;
5928        let mut resolved_canonical_blocks = HashMap::new();
5929        for mutation in validation.mutations() {
5930            if let CanonicalSequenceMutation::Canonical(block) = mutation {
5931                resolved_canonical_blocks
5932                    .entry((block.number, block.hash))
5933                    .and_modify(|known| enrich_block_ref(known, block))
5934                    .or_insert(*block);
5935            }
5936        }
5937        Ok(ChainControlState {
5938            journal_invalidated_from: pre_record_controls
5939                .iter()
5940                .filter_map(|control| match control {
5941                    ChainControl::Reorg {
5942                        common_ancestor, ..
5943                    } => Some(common_ancestor.number.saturating_add(1)),
5944                    _ => None,
5945                })
5946                .min(),
5947            resolved_canonical_blocks,
5948        })
5949    }
5950
5951    fn recover_for_canonical_input(
5952        &mut self,
5953        cache: &mut EvmCache,
5954        block: &BlockRef,
5955        gap_is_certified: bool,
5956        parentless_replacement_is_proven: bool,
5957        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5958    ) -> Option<ReorgReport<N>> {
5959        let latest = self
5960            .coverage_head
5961            .or_else(|| self.journal.back().map(|entry| entry.block))?;
5962
5963        if latest.number == block.number && latest.hash == block.hash {
5964            return None;
5965        }
5966
5967        if self
5968            .journal
5969            .iter()
5970            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
5971        {
5972            return None;
5973        }
5974
5975        if latest.number.checked_add(1) == Some(block.number)
5976            && (block.parent_hash == Some(latest.hash)
5977                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
5978        {
5979            return None;
5980        }
5981
5982        if latest
5983            .number
5984            .checked_add(1)
5985            .is_some_and(|next| block.number > next)
5986        {
5987            // A forward gap: blocks between the journaled head and the arriving
5988            // block were never observed (e.g. a disconnect). A historical
5989            // canonical-progress delivery can instead be covered by a
5990            // compatible post-record progress/barrier certificate proving the
5991            // sparse interval contained no matching events. Live canonical
5992            // gaps remain observable and escalate health.
5993            if !gap_is_certified {
5994                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
5995                health_reports.extend(self.escalate_trust(block.number));
5996                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
5997                    MissedRangeReport {
5998                        from: latest.number + 1,
5999                        to: block.number - 1,
6000                        block: block.number,
6001                        _network: PhantomData,
6002                    },
6003                )));
6004            }
6005            return None;
6006        }
6007
6008        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
6009            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
6010                entry.block.number.checked_add(1) == Some(block.number)
6011                    && entry.block.hash == parent_hash
6012            }) {
6013                let parent = self.journal[parent_index].block;
6014                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
6015                (self.drain_journal_after(parent_index), Some(parent))
6016            } else {
6017                // An unknown immediate parent proves exactly N-1 and nothing
6018                // earlier. Preserve a prefix only when the accepted path is an
6019                // immediate child of the runtime's exact finalized anchor;
6020                // otherwise every cached BLOCKHASH may belong to the displaced
6021                // branch and must be cleared fail-closed.
6022                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
6023                    finalized.number.checked_add(1) == Some(block.number)
6024                        && parent_hash == finalized.hash
6025                });
6026                let invalidated_from = proven_finalized_anchor
6027                    .map_or(0, |finalized| finalized.number.saturating_add(1));
6028                cache.invalidate_cached_block_hashes_from(invalidated_from);
6029                if block.number > 0 {
6030                    // Even when the parent falls outside the retained journal,
6031                    // the arriving child authenticates its exact hash. Restore
6032                    // that one known value after clearing the displaced branch.
6033                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
6034                }
6035                health_reports.extend(self.warn_under_recovery(block.number));
6036                let dropped = if let Some(finalized) = proven_finalized_anchor {
6037                    self.drain_journal_from_number(finalized.number.saturating_add(1))
6038                } else {
6039                    self.drain_journal_from_number(0)
6040                };
6041                (dropped, proven_finalized_anchor)
6042            }
6043        } else {
6044            // No parent identity authenticates any prefix of the arriving path.
6045            cache.invalidate_cached_block_hashes_from(0);
6046            health_reports.extend(self.warn_under_recovery(block.number));
6047            (self.drain_journal_from_number(0), None)
6048        };
6049
6050        self.rebase_validation_state_from(
6051            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
6052        );
6053        let report = self
6054            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
6055            .or_else(|| {
6056                Some(ReorgReport {
6057                    dropped: Some(latest),
6058                    dropped_blocks: Vec::new(),
6059                    dropped_inputs: Vec::new(),
6060                    rollback_updates: Vec::new(),
6061                    rollback_diff: StateDiff::default(),
6062                    purge_updates: Vec::new(),
6063                    purge_diff: StateDiff::default(),
6064                    canceled_resyncs: self
6065                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
6066                    reason: ReorgReason::ParentMismatch,
6067                    _network: PhantomData,
6068                })
6069            });
6070        self.coverage_head = authenticated_anchor;
6071        for head in [&mut self.safe_head, &mut self.finalized_head] {
6072            if head.is_some_and(|head| {
6073                authenticated_anchor.is_none_or(|anchor| {
6074                    head.number > anchor.number
6075                        || (head.number == anchor.number && head.hash != anchor.hash)
6076                })
6077            }) {
6078                *head = None;
6079            }
6080        }
6081        if let Some(anchor) = authenticated_anchor {
6082            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
6083        }
6084        report
6085    }
6086
6087    fn recover_for_reorged_input(
6088        &mut self,
6089        cache: &mut EvmCache,
6090        record: &ReactiveInputRecord<N>,
6091        batch_dropped: &mut BatchDroppedCanonical,
6092        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6093    ) -> Option<ReorgReport<N>> {
6094        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
6095        if batch_dropped.contains(&incoming_dropped_block) {
6096            // A previous signal in this atomic batch already drained this
6097            // block/span. Preserve the lifecycle input report, but do not
6098            // repeat rollback or classify the provider's per-log removals as a
6099            // deep reorg. Exact hash-pinned repairs still need cancellation.
6100            let canceled_resyncs = self
6101                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
6102            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6103                dropped: Some(incoming_dropped_block),
6104                dropped_blocks: vec![incoming_dropped_block],
6105                dropped_inputs: Vec::new(),
6106                rollback_updates: Vec::new(),
6107                rollback_diff: StateDiff::default(),
6108                purge_updates: Vec::new(),
6109                purge_diff: StateDiff::default(),
6110                canceled_resyncs,
6111                reason,
6112                _network: PhantomData,
6113            });
6114        }
6115        let exact_index = self.journal.iter().position(|entry| {
6116            entry.block.number == incoming_dropped_block.number
6117                && entry.block.hash == incoming_dropped_block.hash
6118        });
6119        let mut dropped_block = exact_index
6120            .map(|index| self.journal[index].block)
6121            .or_else(|| {
6122                self.coverage_head.filter(|known| {
6123                    known.number == incoming_dropped_block.number
6124                        && known.hash == incoming_dropped_block.hash
6125                })
6126            })
6127            .unwrap_or(incoming_dropped_block);
6128        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6129        let replacement_is_known = exact_index.is_none()
6130            && (self.journal.iter().any(|entry| {
6131                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
6132            }) || self.coverage_head.is_some_and(|head| {
6133                head.number == dropped_block.number && head.hash != dropped_block.hash
6134            }));
6135
6136        if replacement_is_known {
6137            // A delayed/duplicate removed log for the displaced hash is
6138            // idempotent. Draining by number here would destroy the already
6139            // installed replacement branch at the same height.
6140            let canceled_resyncs =
6141                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6142            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6143                dropped: Some(dropped_block),
6144                dropped_blocks: vec![dropped_block],
6145                dropped_inputs: Vec::new(),
6146                rollback_updates: Vec::new(),
6147                rollback_diff: StateDiff::default(),
6148                purge_updates: Vec::new(),
6149                purge_diff: StateDiff::default(),
6150                canceled_resyncs,
6151                reason,
6152                _network: PhantomData,
6153            });
6154        }
6155
6156        let authenticated_anchor = exact_index.and_then(|index| {
6157            let ancestor_number = dropped_block.number.checked_sub(1)?;
6158            let retained = self
6159                .journal
6160                .iter()
6161                .take(index)
6162                .rev()
6163                .find(|entry| entry.block.number == ancestor_number)
6164                .map(|entry| entry.block);
6165            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
6166                number: ancestor_number,
6167                hash,
6168                parent_hash: None,
6169                timestamp: None,
6170            });
6171            let finalized_fallback = self
6172                .finalized_head
6173                .filter(|head| head.number == ancestor_number);
6174            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
6175            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
6176                .into_iter()
6177                .flatten()
6178            {
6179                if head.number == anchor.number && head.hash == anchor.hash {
6180                    enrich_block_ref(&mut anchor, head);
6181                }
6182            }
6183            Some(anchor)
6184        });
6185
6186        cache.invalidate_cached_block_hashes_from(dropped_block.number);
6187        let dropped = if let Some(index) = exact_index {
6188            self.drain_journal_from(index)
6189        } else {
6190            health_reports.extend(self.warn_under_recovery(dropped_block.number));
6191            self.drain_journal_from_number(dropped_block.number)
6192        };
6193        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
6194        batch_dropped.record_drained(&drained_blocks);
6195        batch_dropped.record_identity(&dropped_block);
6196        self.rebase_validation_state_from(dropped_block.number);
6197
6198        let recovered_journal = !dropped.is_empty();
6199        let report = if !recovered_journal {
6200            let canceled_resyncs =
6201                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6202            Some(ReorgReport {
6203                dropped: Some(dropped_block),
6204                dropped_blocks: Vec::new(),
6205                dropped_inputs: Vec::new(),
6206                rollback_updates: Vec::new(),
6207                rollback_diff: StateDiff::default(),
6208                purge_updates: Vec::new(),
6209                purge_diff: StateDiff::default(),
6210                canceled_resyncs,
6211                reason,
6212                _network: PhantomData,
6213            })
6214        } else {
6215            self.recover_dropped_journals(cache, dropped, reason)
6216        };
6217
6218        if recovered_journal {
6219            if let Some(anchor) = authenticated_anchor {
6220                self.coverage_head = Some(anchor);
6221            }
6222            let coverage = self.coverage_head;
6223            for head in [&mut self.safe_head, &mut self.finalized_head] {
6224                if head.is_some_and(|head| {
6225                    coverage.is_none_or(|coverage| {
6226                        head.number > coverage.number
6227                            || (head.number == coverage.number && head.hash != coverage.hash)
6228                    })
6229                }) {
6230                    *head = None;
6231                }
6232            }
6233        }
6234
6235        if recovered_journal
6236            && report.is_some()
6237            && let Some(head) = self.coverage_head
6238        {
6239            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
6240        }
6241        report
6242    }
6243
6244    /// Warn that a reorg references a block no longer resident in the journal, so
6245    /// recovery is limited to the blocks still journaled — effects from aged-out
6246    /// blocks are neither rolled back nor purged (the freshness/validation loop is
6247    /// the backstop). Makes the under-recovery observable instead of silent.
6248    ///
6249    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
6250    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
6251    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
6252    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
6253    /// transition is returned so the caller can thread it into the ingest cycle's
6254    /// dispatched reports.
6255    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
6256        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
6257        tracing::warn!(
6258            reorg_block = reorg_number,
6259            oldest_journaled = ?oldest_journaled,
6260            journal_depth = self.config.journal_depth,
6261            "reactive reorg recovery is incomplete: the reorged block is no longer \
6262             in the journal, so effects from blocks aged out of the journal are \
6263             neither rolled back nor purged (the freshness/validation loop is the \
6264             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
6265             reorgs precisely."
6266        );
6267
6268        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
6269
6270        self.escalate_trust(reorg_number)
6271    }
6272
6273    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
6274        advance_or_enrich_coverage(&mut self.coverage_head, block);
6275        let entry = self.journal_entry_mut(block);
6276        let enriched = entry.block;
6277        if !entry.inputs.contains(&input_ref) {
6278            entry.inputs.push(input_ref);
6279        }
6280        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
6281        self.trim_journal();
6282    }
6283
6284    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6285        let entry = self.journal_entry_mut(block);
6286        if !entry.handler_ids.contains(&applied.handler_id) {
6287            entry.handler_ids.push(applied.handler_id.clone());
6288        }
6289        entry.rollback_diffs.push(applied.diff.clone());
6290        entry.applied.push(applied);
6291        self.trim_journal();
6292    }
6293
6294    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6295        let Some(entry) = self
6296            .journal
6297            .iter_mut()
6298            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
6299        else {
6300            return;
6301        };
6302        if !entry.handler_ids.contains(&applied.handler_id) {
6303            entry.handler_ids.push(applied.handler_id.clone());
6304        }
6305        entry.rollback_diffs.push(applied.diff.clone());
6306        entry.applied.push(applied);
6307    }
6308
6309    fn record_journal_resync(&mut self, report: &ResyncReport) {
6310        if report.diff.is_empty() {
6311            return;
6312        }
6313        let Some(block) = single_hash_pinned_resync_block(report) else {
6314            return;
6315        };
6316        let entry = self.journal_entry_mut(&block);
6317        entry.rollback_diffs.push(report.diff.clone());
6318        entry.resynced.push(report.clone());
6319        self.trim_journal();
6320    }
6321
6322    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
6323        if let Some(index) = self
6324            .journal
6325            .iter()
6326            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6327        {
6328            enrich_block_ref(&mut self.journal[index].block, block);
6329            return &mut self.journal[index];
6330        }
6331
6332        self.journal.push_back(BlockJournal {
6333            block: *block,
6334            inputs: Vec::new(),
6335            applied: Vec::new(),
6336            handler_ids: Vec::new(),
6337            resynced: Vec::new(),
6338            rollback_diffs: Vec::new(),
6339        });
6340        let index = self.journal.len() - 1;
6341        &mut self.journal[index]
6342    }
6343
6344    fn trim_journal(&mut self) {
6345        if self.config.journal_depth == 0 {
6346            self.journal.clear();
6347            return;
6348        }
6349        while self.journal.len() > self.config.journal_depth {
6350            self.journal.pop_front();
6351        }
6352    }
6353
6354    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6355        self.journal.drain((index + 1)..).collect()
6356    }
6357
6358    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6359        self.journal.drain(index..).collect()
6360    }
6361
6362    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
6363        let Some(index) = self
6364            .journal
6365            .iter()
6366            .position(|entry| entry.block.number >= number)
6367        else {
6368            return Vec::new();
6369        };
6370        self.drain_journal_from(index)
6371    }
6372
6373    fn recover_dropped_journals(
6374        &mut self,
6375        cache: &mut EvmCache,
6376        dropped: Vec<BlockJournal<N>>,
6377        reason: ReorgReason,
6378    ) -> Option<ReorgReport<N>> {
6379        if dropped.is_empty() {
6380            return None;
6381        }
6382
6383        let first_dropped_block = dropped
6384            .iter()
6385            .map(|entry| entry.block.number)
6386            .min()
6387            .expect("non-empty dropped journal set");
6388        self.rebase_validation_state_from(first_dropped_block);
6389        if self
6390            .safe_head
6391            .is_some_and(|head| head.number >= first_dropped_block)
6392        {
6393            self.safe_head = None;
6394        }
6395
6396        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
6397        let dropped_inputs: Vec<_> = dropped
6398            .iter()
6399            .flat_map(|entry| entry.inputs.iter().copied())
6400            .collect();
6401        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
6402        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
6403        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
6404        let purge_updates: Vec<_> = purge_scopes
6405            .iter()
6406            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
6407            .collect();
6408
6409        let rollback_diff = if rollback_updates.is_empty() {
6410            StateDiff::default()
6411        } else {
6412            cache.apply_updates(&rollback_updates)
6413        };
6414        let purge_diff = if purge_updates.is_empty() {
6415            StateDiff::default()
6416        } else {
6417            cache.apply_updates(&purge_updates)
6418        };
6419        self.coverage_head = self.journal.back().map(|entry| entry.block);
6420
6421        Some(ReorgReport {
6422            dropped: dropped_blocks.first().cloned(),
6423            dropped_blocks,
6424            dropped_inputs,
6425            rollback_updates,
6426            rollback_diff,
6427            purge_updates,
6428            purge_diff,
6429            canceled_resyncs,
6430            reason,
6431            _network: PhantomData,
6432        })
6433    }
6434
6435    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
6436        if let Some(freshness) = self.freshness.as_mut() {
6437            freshness.invalidate_valid_through_from(first_dropped_block);
6438        }
6439        self.tracked_roots
6440            .retain(|_, baseline| baseline.last_block < first_dropped_block);
6441        if self
6442            .last_gate_block
6443            .is_some_and(|block| block >= first_dropped_block)
6444        {
6445            self.last_gate_block = self
6446                .tracked_roots
6447                .values()
6448                .map(|baseline| baseline.last_block)
6449                .max();
6450        }
6451        // Touch provenance is window-relative. Once any block in that window
6452        // is dropped, retaining the union could incorrectly mark a replacement
6453        // branch root move as decoder-covered.
6454        self.touched_since_gate.clear();
6455    }
6456
6457    fn cancel_resyncs_for_dropped_blocks(
6458        &mut self,
6459        dropped_blocks: &[BlockRef],
6460    ) -> Vec<ResyncRequest> {
6461        let mut canceled = Vec::new();
6462        self.pending_resyncs.retain(|request| {
6463            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6464            if should_cancel {
6465                canceled.push(request.clone());
6466            }
6467            !should_cancel
6468        });
6469        canceled
6470    }
6471
6472    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6473        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6474        self.pending_resyncs
6475            .retain(|request| !ids.contains(&request.id));
6476    }
6477}
6478
6479fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
6480    cache.set_block(BlockId::pending());
6481    cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
6482    cache.set_coinbase(flashblock.beneficiary);
6483    cache.set_prevrandao(flashblock.prevrandao);
6484    cache.set_block_gas_limit(flashblock.gas_limit);
6485    cache.set_timestamp(flashblock.timestamp);
6486}
6487
6488/// Validate one provider-neutral delivery envelope without mutating runtime or
6489/// cache state.
6490///
6491/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6492/// composite/remote subscribers. It validates explicit reorg controls before
6493/// records, canonical record identity and implicit-reorg finality, then
6494/// progress/barrier/safe/finalized controls. All identity assertions in the
6495/// envelope must agree at each height. Retained history may be sparse; an
6496/// explicit common ancestor need not itself be retained when the oldest
6497/// retained entry is at or below it. Ancestors and removed blocks outside that
6498/// rollback horizon are rejected, so a durable caller cannot persist a partial
6499/// rollback. The runtime uses this same implementation with an internal
6500/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6501///
6502/// The returned state and mutations are cache-free. Callers that durably stage
6503/// delivery should publish/persist them only at their own acknowledgement
6504/// boundary.
6505///
6506/// This validator is deliberately chain-agnostic and does not compare
6507/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6508/// no chain id. Cross-service/composite callers must bind one authoritative
6509/// chain identity outside this state before sharing or advancing it; runtime
6510/// ingestion separately checks the batch id against [`EvmCache`].
6511///
6512/// # Errors
6513///
6514/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6515/// metadata is malformed or conflicting, and
6516/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6517/// invalid canonical transition, incomplete rollback proof, contradictory
6518/// identity, or invalid coverage/finality relationship.
6519pub fn validate_canonical_sequence<N: Network>(
6520    state: &CanonicalSequenceState,
6521    batch: &ReactiveInputBatch<N>,
6522) -> Result<CanonicalSequenceValidation, ReactiveError> {
6523    validate_canonical_sequence_diagnostic(state, batch)
6524        .map_err(CanonicalSequenceError::into_reactive_error)
6525}
6526
6527/// Validate one provider-neutral delivery envelope and retain structured
6528/// rollback diagnostics.
6529///
6530/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6531/// it at durable/composite source boundaries that need to distinguish malformed
6532/// input from an otherwise valid transition whose rollback ancestor has aged
6533/// out of the retained history. Callers should branch on
6534/// [`CanonicalSequenceError`] rather than parsing error text.
6535///
6536/// # Errors
6537///
6538/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6539/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6540/// retained canonical history is required to prove the transition.
6541pub fn validate_canonical_sequence_diagnostic<N: Network>(
6542    state: &CanonicalSequenceState,
6543    batch: &ReactiveInputBatch<N>,
6544) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6545    validate_canonical_sequence_internal(
6546        state,
6547        batch,
6548        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6549    )
6550}
6551
6552/// Validate a composite-source envelope and normalize harmless coverage
6553/// overlap.
6554///
6555/// This has the same fail-closed rollback/finality/identity contract as
6556/// [`validate_canonical_sequence`]. In addition, an equal or older
6557/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6558/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6559/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6560/// same opaque id and `block: None`, preserving the synchronization event
6561/// without forwarding regressive coverage. An equal-height control that fills
6562/// absent parent/timestamp metadata is retained and applied. Older compatible
6563/// metadata enrichment is deliberately dropped together with its non-forwarded
6564/// control so the returned state remains identical to what the runtime will
6565/// observe. Unknown or conflicting stale identities remain errors.
6566///
6567/// # Errors
6568///
6569/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6570/// record identity/payload metadata, and
6571/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6572/// proven redundant or when rollback, adjacency, identity, coverage, or
6573/// finality validation fails.
6574pub fn normalize_and_validate_canonical_sequence<N: Network>(
6575    state: &CanonicalSequenceState,
6576    batch: &ReactiveInputBatch<N>,
6577) -> Result<CanonicalSequenceValidation, ReactiveError> {
6578    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6579        .map_err(CanonicalSequenceError::into_reactive_error)
6580}
6581
6582/// Validate and normalize one composite-source envelope while retaining
6583/// structured rollback diagnostics.
6584///
6585/// This is the diagnostic counterpart to
6586/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6587/// and normalization semantics, but reports history exhaustion as
6588/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6589/// prose [`ReactiveError::InvalidChainControl`].
6590///
6591/// # Errors
6592///
6593/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6594/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6595/// when the retained history cannot prove a complete rollback.
6596pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6597    state: &CanonicalSequenceState,
6598    batch: &ReactiveInputBatch<N>,
6599) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6600    validate_canonical_sequence_internal(
6601        state,
6602        batch,
6603        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6604    )
6605}
6606
6607fn validate_canonical_sequence_internal<N: Network>(
6608    state: &CanonicalSequenceState,
6609    batch: &ReactiveInputBatch<N>,
6610    policy: CanonicalSequenceValidationPolicy,
6611) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6612    let records = batch
6613        .records()
6614        .iter()
6615        .enumerate()
6616        .map(|(index, record)| {
6617            (
6618                record.clone(),
6619                DeliveryAudience::All,
6620                batch
6621                    .record_delivery_scope(index)
6622                    .expect("enumerated record always has a delivery scope"),
6623            )
6624        })
6625        .collect::<Vec<_>>();
6626    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6627    let records = records
6628        .iter()
6629        .map(|(record, _, scope)| (record, *scope))
6630        .collect::<Vec<_>>();
6631    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6632}
6633
6634#[derive(Clone, Copy)]
6635enum CanonicalSequenceValidationPolicy {
6636    RequireCompleteRollback,
6637    RequireCompleteRollbackNormalizeCoverage,
6638    ObserveIncompleteRollback,
6639}
6640
6641/// Stable category for a canonical transition that needs older retained
6642/// history before it can be durably accepted.
6643#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6644#[non_exhaustive]
6645pub enum CanonicalRollbackKind {
6646    /// An explicit reorg control names an ancestor outside retained history.
6647    Explicit,
6648    /// A removed/reorged record names a block outside retained history.
6649    Removed,
6650    /// An implicit canonical replacement has no retained parent proof.
6651    ImplicitParent,
6652    /// A removed block is not followed by a provable replacement/anchor.
6653    MissingReplacement,
6654}
6655
6656/// Structured failure returned by canonical-sequence diagnostic validation.
6657///
6658/// This type is intentionally independent of diagnostic prose so remote and
6659/// composite subscribers can select recovery behavior without string matching.
6660#[derive(Debug, thiserror::Error)]
6661#[non_exhaustive]
6662pub enum CanonicalSequenceError {
6663    /// The snapshot or envelope is intrinsically malformed or contradictory.
6664    #[error(transparent)]
6665    Invalid(#[from] ReactiveError),
6666    /// The transition may be valid, but its rollback proof lies outside the
6667    /// supplied retained canonical history.
6668    #[error(
6669        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6670    )]
6671    IncompleteRollback {
6672        /// Last ancestor height required to prove the rollback.
6673        common_ancestor: u64,
6674        /// Oldest retained canonical height supplied by the caller.
6675        oldest_retained: Option<u64>,
6676        /// Stable reason the history window is insufficient.
6677        kind: CanonicalRollbackKind,
6678    },
6679}
6680
6681#[derive(Clone, Copy, Debug)]
6682struct RequiredReorgAnchor {
6683    number: u64,
6684    block: Option<BlockRef>,
6685    permits_missing_child_parent: bool,
6686    must_be_consumed: bool,
6687}
6688
6689#[derive(Debug)]
6690struct SequenceRewind {
6691    common_ancestor: Option<BlockRef>,
6692    dropped: Vec<BlockRef>,
6693}
6694
6695impl RequiredReorgAnchor {
6696    const fn hash(self) -> Option<B256> {
6697        match self.block {
6698            Some(block) => Some(block.hash),
6699            None => None,
6700        }
6701    }
6702}
6703
6704impl CanonicalSequenceError {
6705    /// Whether retrying with an older retained history window may prove this
6706    /// same transition.
6707    pub const fn requires_history(&self) -> bool {
6708        matches!(self, Self::IncompleteRollback { .. })
6709    }
6710
6711    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6712    pub fn into_reactive_error(self) -> ReactiveError {
6713        match self {
6714            Self::Invalid(error) => error,
6715            Self::IncompleteRollback {
6716                common_ancestor,
6717                oldest_retained,
6718                kind,
6719            } => ReactiveError::InvalidChainControl {
6720                message: format!(
6721                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6722                ),
6723            },
6724        }
6725    }
6726}
6727
6728impl CanonicalSequenceValidationPolicy {
6729    const fn requires_complete_rollback(self) -> bool {
6730        matches!(
6731            self,
6732            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6733        )
6734    }
6735
6736    const fn normalizes_coverage(self) -> bool {
6737        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6738    }
6739}
6740
6741fn validate_canonical_sequence_parts<N: Network>(
6742    initial: &CanonicalSequenceState,
6743    controls: &[ChainControl],
6744    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6745    policy: CanonicalSequenceValidationPolicy,
6746) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6747    validate_canonical_sequence_snapshot(initial)?;
6748    let control_split = validate_control_phase_order(controls)?;
6749    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6750    let mut state = initial.clone();
6751    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6752    let mut mutations = Vec::new();
6753    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6754    let mut batch_dropped = BatchDroppedCanonical::default();
6755    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6756    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6757    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6758    let rollback_oldest = initial
6759        .retained_canonical_history
6760        .first()
6761        .map(|block| block.number);
6762
6763    for control in pre_record_controls {
6764        normalized_chain_controls.push(control.clone());
6765        validate_sequence_control(&state, control)?;
6766        assert_chain_control_identities(&mut asserted_blocks, control)?;
6767        let ChainControl::Reorg {
6768            common_ancestor,
6769            old_tip,
6770            ..
6771        } = control
6772        else {
6773            unreachable!("phase validation leaves only reorg controls before records")
6774        };
6775        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6776            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6777        });
6778        let rollback_horizon_covers_ancestor = state
6779            .retained_canonical_history
6780            .first()
6781            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6782        if policy.requires_complete_rollback()
6783            && !exact_ancestor
6784            && !rollback_horizon_covers_ancestor
6785        {
6786            return Err(CanonicalSequenceError::IncompleteRollback {
6787                common_ancestor: common_ancestor.number,
6788                oldest_retained: rollback_oldest,
6789                kind: CanonicalRollbackKind::Explicit,
6790            });
6791        }
6792        let dropped = state
6793            .retained_canonical_history
6794            .iter()
6795            .copied()
6796            .filter(|block| block.number > common_ancestor.number)
6797            .collect::<Vec<_>>();
6798        state
6799            .retained_canonical_history
6800            .retain(|block| block.number <= common_ancestor.number);
6801        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6802        let mut enriched_ancestor = *common_ancestor;
6803        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6804            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6805        }) {
6806            enrich_block_ref(&mut enriched_ancestor, retained);
6807        }
6808        if let Some(coverage) = state.coverage_head.as_ref()
6809            && coverage.number == common_ancestor.number
6810            && coverage.hash == common_ancestor.hash
6811        {
6812            enrich_block_ref(&mut enriched_ancestor, coverage);
6813        }
6814        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6815        state.coverage_head = Some(enriched_ancestor);
6816        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6817        batch_dropped.record_explicit(common_ancestor, old_tip);
6818        batch_dropped.record_drained(&dropped);
6819        mutations.push(CanonicalSequenceMutation::Rewind {
6820            common_ancestor: Some(enriched_ancestor),
6821            dropped,
6822        });
6823    }
6824    let pre_record_state = state.clone();
6825    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6826
6827    for (record, scope) in records {
6828        if !scope.advances_canonical_state() {
6829            continue;
6830        }
6831        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6832            let incoming_dropped_block =
6833                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6834            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6835            validate_sequence_adjacent_parent_identity(
6836                &state,
6837                &incoming_dropped_block,
6838                "removed record",
6839            )?;
6840            let mut dropped_block = state
6841                .retained_canonical_history
6842                .iter()
6843                .find(|known| {
6844                    known.number == incoming_dropped_block.number
6845                        && known.hash == incoming_dropped_block.hash
6846                })
6847                .copied()
6848                .or_else(|| {
6849                    state.coverage_head.filter(|known| {
6850                        known.number == incoming_dropped_block.number
6851                            && known.hash == incoming_dropped_block.hash
6852                    })
6853                })
6854                .unwrap_or(incoming_dropped_block);
6855            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6856            validate_sequence_implicit_finality(&state, record, None)?;
6857            if dropped_block.number == 0 {
6858                return Err(ReactiveError::InvalidChainControl {
6859                    message: "a removed/reorged genesis block has no canonical parent anchor"
6860                        .into(),
6861                }
6862                .into());
6863            }
6864            let removed_identity = (dropped_block.number, dropped_block.hash);
6865            if let Some(previous_number) =
6866                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6867                && previous_number != dropped_block.number
6868            {
6869                return Err(ReactiveError::InvalidChainControl {
6870                    message: format!(
6871                        "removed hash {:?} is reused at heights {} and {}",
6872                        dropped_block.hash, previous_number, dropped_block.number
6873                    ),
6874                }
6875                .into());
6876            }
6877            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6878                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6879                    return Err(ReactiveError::InvalidChainControl {
6880                        message: format!(
6881                            "duplicate removed block {}:{:?} carries conflicting metadata",
6882                            dropped_block.number, dropped_block.hash
6883                        ),
6884                    }
6885                    .into());
6886                }
6887                enrich_block_ref(previous, &dropped_block);
6888            } else {
6889                removed_assertions.insert(removed_identity, dropped_block);
6890            }
6891            if asserted_blocks
6892                .get(&dropped_block.number)
6893                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6894            {
6895                return Err(ReactiveError::InvalidChainControl {
6896                    message: format!(
6897                        "removed block {}:{:?} is asserted canonical by the same envelope",
6898                        dropped_block.number, dropped_block.hash
6899                    ),
6900                }
6901                .into());
6902            }
6903            if batch_dropped.contains(&dropped_block) {
6904                continue;
6905            }
6906            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
6907                block.number == dropped_block.number && block.hash == dropped_block.hash
6908            }) {
6909                let dropped = state.retained_canonical_history.split_off(index);
6910                batch_dropped.record_drained(&dropped);
6911                let ancestor_number = dropped_block
6912                    .number
6913                    .checked_sub(1)
6914                    .expect("genesis removal was rejected above");
6915                let retained_anchor = state
6916                    .retained_canonical_history
6917                    .iter()
6918                    .rev()
6919                    .find(|head| head.number == ancestor_number)
6920                    .copied();
6921                let authenticated_anchor = retained_anchor
6922                    .or_else(|| {
6923                        dropped_block.parent_hash.map(|hash| BlockRef {
6924                            number: ancestor_number,
6925                            hash,
6926                            parent_hash: None,
6927                            timestamp: None,
6928                        })
6929                    })
6930                    .or_else(|| {
6931                        state
6932                            .finalized_head
6933                            .filter(|head| head.number == ancestor_number)
6934                    });
6935                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
6936                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
6937                        .into_iter()
6938                        .flatten()
6939                    {
6940                        if head.number == anchor.number && head.hash == anchor.hash {
6941                            enrich_block_ref(&mut anchor, head);
6942                        }
6943                    }
6944                    anchor
6945                });
6946                required_reorg_anchor = Some(RequiredReorgAnchor {
6947                    number: ancestor_number,
6948                    block: authenticated_anchor,
6949                    permits_missing_child_parent: retained_anchor.is_some(),
6950                    must_be_consumed: authenticated_anchor.is_none()
6951                        && state.retained_canonical_history.is_empty(),
6952                });
6953                state.coverage_head = authenticated_anchor
6954                    .or_else(|| state.retained_canonical_history.last().copied());
6955                if let Some(head) = state.coverage_head {
6956                    clear_sequence_heads_above(&mut state, &head);
6957                } else {
6958                    state.safe_head = None;
6959                    state.finalized_head = None;
6960                }
6961                mutations.push(CanonicalSequenceMutation::Rewind {
6962                    common_ancestor: state.coverage_head,
6963                    dropped,
6964                });
6965            } else {
6966                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
6967                    block.number == dropped_block.number && block.hash != dropped_block.hash
6968                }) || state.coverage_head.is_some_and(|head| {
6969                    head.number == dropped_block.number && head.hash != dropped_block.hash
6970                });
6971                if !replacement_is_known {
6972                    // Ordinary runtime ingestion deliberately keeps an unknown
6973                    // deep removal observable and lets the recovery path
6974                    // degrade health. With no exact retained rollback proof,
6975                    // this validator must not fabricate a new canonical head.
6976                    if policy.requires_complete_rollback() {
6977                        return Err(CanonicalSequenceError::IncompleteRollback {
6978                            common_ancestor: dropped_block
6979                                .number
6980                                .checked_sub(1)
6981                                .expect("genesis removal was rejected above"),
6982                            oldest_retained: rollback_oldest,
6983                            kind: CanonicalRollbackKind::Removed,
6984                        });
6985                    }
6986                    continue;
6987                }
6988            }
6989            continue;
6990        }
6991
6992        let Some(context_block) = canonical_record_block(record) else {
6993            continue;
6994        };
6995        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
6996        if post_record_controls
6997            .iter()
6998            .filter_map(canonical_coverage_control_block)
6999            .any(|asserted| {
7000                asserted.number == incoming_block.number
7001                    && asserted.hash == incoming_block.hash
7002                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
7003                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
7004                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
7005            })
7006        {
7007            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
7008        }
7009        let mut resolved_block = incoming_block;
7010        if let Some(asserted) = asserted_blocks
7011            .get(&incoming_block.number)
7012            .filter(|asserted| asserted.hash == incoming_block.hash)
7013        {
7014            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
7015                return Err(ReactiveError::InvalidChainControl {
7016                    message: format!(
7017                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7018                        incoming_block.number, incoming_block.hash
7019                    ),
7020                }
7021                .into());
7022            }
7023            enrich_block_ref(&mut resolved_block, asserted);
7024        }
7025        for asserted in post_record_controls
7026            .iter()
7027            .filter_map(chain_control_canonical_assertion)
7028            .filter(|asserted| {
7029                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
7030            })
7031        {
7032            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
7033                return Err(ReactiveError::InvalidChainControl {
7034                    message: format!(
7035                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
7036                        incoming_block.number, incoming_block.hash
7037                    ),
7038                }
7039                .into());
7040            }
7041            enrich_block_ref(&mut resolved_block, asserted);
7042        }
7043        let replacement_anchor =
7044            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
7045        if resolved_block.parent_hash.is_none()
7046            && replacement_anchor.is_some_and(|anchor| {
7047                anchor.permits_missing_child_parent
7048                    && anchor.number.checked_add(1) == Some(resolved_block.number)
7049            })
7050        {
7051            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
7052        }
7053        let block = &resolved_block;
7054        if removed_assertions.contains_key(&(block.number, block.hash)) {
7055            return Err(ReactiveError::InvalidChainControl {
7056                message: format!(
7057                    "canonical block {}:{:?} is also removed by the same envelope",
7058                    block.number, block.hash
7059                ),
7060            }
7061            .into());
7062        }
7063        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7064            && *removed_number != block.number
7065        {
7066            return Err(ReactiveError::InvalidChainControl {
7067                message: format!(
7068                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7069                    block.hash, block.number, removed_number
7070                ),
7071            }
7072            .into());
7073        }
7074        let replacement_proven_by_removal =
7075            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7076        if replacement_anchor.is_some() {
7077            required_reorg_anchor = None;
7078        }
7079        validate_sequence_matching_metadata(&state, block, "canonical record")?;
7080        validate_sequence_implicit_finality(&state, record, Some(block))?;
7081        let implicit_replacement_requires_history = if replacement_proven_by_removal {
7082            false
7083        } else {
7084            sequence_implicit_replacement_requires_history(&state, block, policy)?
7085        };
7086        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
7087            return Err(CanonicalSequenceError::IncompleteRollback {
7088                common_ancestor: block.number.saturating_sub(1),
7089                oldest_retained: rollback_oldest,
7090                kind: CanonicalRollbackKind::ImplicitParent,
7091            });
7092        }
7093        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
7094        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7095            anchor.permits_missing_child_parent
7096                && anchor.number.checked_add(1) == Some(block.number)
7097        });
7098        if let Some(rewind) =
7099            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7100        {
7101            mutations.push(CanonicalSequenceMutation::Rewind {
7102                common_ancestor: rewind.common_ancestor,
7103                dropped: rewind.dropped,
7104            });
7105        }
7106        mutations.push(CanonicalSequenceMutation::Canonical(*block));
7107    }
7108
7109    for control in post_record_controls {
7110        if let Some(block) = chain_control_canonical_assertion(control)
7111            && removed_assertions.contains_key(&(block.number, block.hash))
7112        {
7113            return Err(ReactiveError::InvalidChainControl {
7114                message: format!(
7115                    "canonical block {}:{:?} is also removed by the same envelope",
7116                    block.number, block.hash
7117                ),
7118            }
7119            .into());
7120        }
7121        if let Some(block) = chain_control_canonical_assertion(control)
7122            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7123            && *removed_number != block.number
7124        {
7125            return Err(ReactiveError::InvalidChainControl {
7126                message: format!(
7127                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7128                    block.hash, block.number, removed_number
7129                ),
7130            }
7131            .into());
7132        }
7133        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
7134            required_reorg_anchor.filter(|required| block.number > required.number)
7135        });
7136        if let Some(block) = canonical_coverage_control_block(control) {
7137            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7138            if replacement_anchor.is_some() {
7139                required_reorg_anchor = None;
7140            }
7141        }
7142        assert_chain_control_identities(&mut asserted_blocks, control)?;
7143        let preserves_record_proof =
7144            canonical_coverage_control_block(control).is_some_and(|block| {
7145                record_proof_control_identities.contains(&(block.number, block.hash))
7146            });
7147        if policy.normalizes_coverage()
7148            && !preserves_record_proof
7149            && let Some(block) = canonical_coverage_control_block(control)
7150            && state
7151                .coverage_head
7152                .is_some_and(|head| block.number <= head.number)
7153        {
7154            let is_equal_coverage = state
7155                .coverage_head
7156                .is_some_and(|head| block.number == head.number);
7157            let known = state
7158                .coverage_head
7159                .as_ref()
7160                .filter(|head| head.number == block.number && head.hash == block.hash)
7161                .or_else(|| {
7162                    state
7163                        .retained_canonical_history
7164                        .iter()
7165                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
7166                });
7167            if let Some(known) = known
7168                && optional_block_refs_are_compatible(Some(known), Some(block))
7169                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
7170            {
7171                if let ChainControl::Barrier { id, .. } = control {
7172                    normalized_chain_controls.push(ChainControl::Barrier {
7173                        id: id.clone(),
7174                        block: None,
7175                    });
7176                }
7177                continue;
7178            }
7179        }
7180        validate_sequence_control(&state, control)?;
7181        normalized_chain_controls.push(control.clone());
7182        match control {
7183            ChainControl::Safe(block) => {
7184                set_or_enrich_block_ref(&mut state.safe_head, block);
7185                mutations.push(CanonicalSequenceMutation::Safe(
7186                    state.safe_head.expect("safe head was just installed"),
7187                ));
7188            }
7189            ChainControl::Finalized(block) => {
7190                set_or_enrich_block_ref(&mut state.finalized_head, block);
7191                mutations.push(CanonicalSequenceMutation::Finalized(
7192                    state
7193                        .finalized_head
7194                        .expect("finalized head was just installed"),
7195                ));
7196            }
7197            ChainControl::CanonicalProgress(block)
7198            | ChainControl::Barrier {
7199                block: Some(block), ..
7200            } => {
7201                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7202                    anchor.permits_missing_child_parent
7203                        && anchor.number.checked_add(1) == Some(block.number)
7204                }) || (replacement_anchor.is_none()
7205                    && block.parent_hash.is_none()
7206                    && state
7207                        .coverage_head
7208                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
7209                if let Some(rewind) =
7210                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7211                {
7212                    mutations.push(CanonicalSequenceMutation::Rewind {
7213                        common_ancestor: rewind.common_ancestor,
7214                        dropped: rewind.dropped,
7215                    });
7216                }
7217                mutations.push(CanonicalSequenceMutation::Canonical(*block));
7218            }
7219            ChainControl::Barrier { block: None, .. } => {}
7220            ChainControl::Reorg { .. } => {
7221                unreachable!("phase validation excludes post-record reorg controls")
7222            }
7223        }
7224    }
7225
7226    if let Some(required) = required_reorg_anchor
7227        && required.must_be_consumed
7228        && policy.requires_complete_rollback()
7229    {
7230        return Err(CanonicalSequenceError::IncompleteRollback {
7231            common_ancestor: required.number,
7232            oldest_retained: rollback_oldest,
7233            kind: CanonicalRollbackKind::MissingReplacement,
7234        });
7235    }
7236
7237    validate_canonical_sequence_snapshot(&state)?;
7238    Ok(CanonicalSequenceValidation {
7239        pre_record_state,
7240        next_state: state,
7241        mutations,
7242        normalized_chain_controls,
7243    })
7244}
7245
7246fn validate_canonical_sequence_snapshot(
7247    state: &CanonicalSequenceState,
7248) -> Result<(), ReactiveError> {
7249    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7250    let supplied_blocks = state
7251        .retained_canonical_history
7252        .iter()
7253        .chain(state.coverage_head.iter())
7254        .chain(state.safe_head.iter())
7255        .chain(state.finalized_head.iter())
7256        .collect::<Vec<_>>();
7257    validate_known_parent_hash_heights(&supplied_blocks)?;
7258    let mut prior = None::<BlockRef>;
7259    for block in &state.retained_canonical_history {
7260        if let Some(previous) = prior {
7261            if block.number < previous.number {
7262                return Err(invalid(
7263                    "retained canonical history is not ordered by block number".into(),
7264                ));
7265            }
7266            if block.number == previous.number {
7267                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
7268                {
7269                    "duplicate"
7270                } else {
7271                    "conflicting"
7272                };
7273                return Err(invalid(format!(
7274                    "retained canonical history contains {qualifier} identities at block {}",
7275                    block.number
7276                )));
7277            }
7278            if previous.number.checked_add(1) == Some(block.number)
7279                && block.parent_hash.is_some()
7280                && block.parent_hash != Some(previous.hash)
7281            {
7282                return Err(invalid(format!(
7283                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
7284                    block.number, block.hash, previous.number, previous.hash
7285                )));
7286            }
7287        }
7288        prior = Some(*block);
7289    }
7290    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
7291        return Err(invalid(
7292            "retained canonical history requires an authoritative coverage head".into(),
7293        ));
7294    }
7295    if let Some(head) = state.coverage_head.as_ref() {
7296        if let Some(retained) = state
7297            .retained_canonical_history
7298            .iter()
7299            .find(|entry| entry.number == head.number)
7300            && !optional_block_refs_are_compatible(Some(retained), Some(head))
7301        {
7302            return Err(invalid(format!(
7303                "coverage head {}:{:?} conflicts with retained identity {:?}",
7304                head.number, head.hash, retained
7305            )));
7306        }
7307        if state
7308            .retained_canonical_history
7309            .last()
7310            .is_some_and(|retained| retained.number > head.number)
7311        {
7312            return Err(invalid(
7313                "retained canonical history advances beyond the coverage head".into(),
7314            ));
7315        }
7316        if let Some(retained) = state.retained_canonical_history.last()
7317            && retained.number.checked_add(1) == Some(head.number)
7318            && head.parent_hash.is_some()
7319            && head.parent_hash != Some(retained.hash)
7320        {
7321            return Err(invalid(format!(
7322                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
7323                head.number, head.hash, retained.number, retained.hash
7324            )));
7325        }
7326    }
7327    if let Some(safe) = state.safe_head.as_ref() {
7328        validate_sequence_known_identity(state, safe, "safe")?;
7329        validate_sequence_head_within_coverage(state, safe, "safe")?;
7330        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
7331    }
7332    if let Some(finalized) = state.finalized_head.as_ref() {
7333        validate_sequence_known_identity(state, finalized, "finalized")?;
7334        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
7335        validate_coverage_descends_from_adjacent_head(
7336            state.coverage_head.as_ref(),
7337            finalized,
7338            "finalized",
7339        )?;
7340    }
7341    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
7342    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
7343        && (finalized.number > safe.number
7344            || (finalized.number == safe.number && finalized.hash != safe.hash))
7345    {
7346        return Err(invalid(
7347            "finalized head cannot advance beyond or conflict with safe head".into(),
7348        ));
7349    }
7350    Ok(())
7351}
7352
7353fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
7354    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
7355    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
7356    for block in blocks.iter().copied() {
7357        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
7358            && previous_height != block.number
7359        {
7360            return Err(ReactiveError::InvalidChainControl {
7361                message: format!(
7362                    "canonical hash {:?} is reused at heights {} and {}",
7363                    block.hash, previous_height, block.number
7364                ),
7365            });
7366        }
7367        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
7368            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
7369                return Err(ReactiveError::InvalidChainControl {
7370                    message: format!(
7371                        "canonical aliases at height {} carry conflicting identities or metadata",
7372                        block.number
7373                    ),
7374                });
7375            }
7376            enrich_block_ref(resolved, block);
7377        } else {
7378            resolved_by_height.insert(block.number, *block);
7379        }
7380    }
7381    for child in resolved_by_height.values() {
7382        let Some(parent_hash) = child.parent_hash else {
7383            continue;
7384        };
7385        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
7386            && parent_number.checked_add(1) != Some(child.number)
7387        {
7388            return Err(ReactiveError::InvalidChainControl {
7389                message: format!(
7390                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7391                    child.number, child.hash, parent_hash, parent_number
7392                ),
7393            });
7394        }
7395        if let Some(parent_number) = child.number.checked_sub(1)
7396            && let Some(parent) = resolved_by_height.get(&parent_number)
7397            && parent.hash != parent_hash
7398        {
7399            return Err(ReactiveError::InvalidChainControl {
7400                message: format!(
7401                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
7402                    child.number, child.hash, parent.number, parent.hash
7403                ),
7404            });
7405        }
7406    }
7407    Ok(())
7408}
7409
7410fn validate_coverage_descends_from_adjacent_head(
7411    coverage: Option<&BlockRef>,
7412    head: &BlockRef,
7413    label: &str,
7414) -> Result<(), ReactiveError> {
7415    let Some(coverage) = coverage else {
7416        return Ok(());
7417    };
7418    if head.number.checked_add(1) == Some(coverage.number)
7419        && coverage
7420            .parent_hash
7421            .is_some_and(|parent| parent != head.hash)
7422    {
7423        return Err(ReactiveError::InvalidChainControl {
7424            message: format!(
7425                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
7426                coverage.number, coverage.hash, head.number, head.hash
7427            ),
7428        });
7429    }
7430    Ok(())
7431}
7432
7433fn validate_sequence_control(
7434    state: &CanonicalSequenceState,
7435    control: &ChainControl,
7436) -> Result<(), ReactiveError> {
7437    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7438    match control {
7439        ChainControl::Safe(block) => {
7440            validate_sequence_known_identity(state, block, "safe")?;
7441            validate_sequence_head_within_coverage(state, block, "safe")?;
7442            if let Some(current) = state.safe_head.as_ref()
7443                && (block.number < current.number
7444                    || (block.number == current.number
7445                        && (block.hash != current.hash
7446                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7447            {
7448                return Err(invalid(format!(
7449                    "safe head {}:{:?} conflicts with current {}:{:?}",
7450                    block.number, block.hash, current.number, current.hash
7451                )));
7452            }
7453            if let Some(finalized) = state.finalized_head.as_ref()
7454                && (block.number < finalized.number
7455                    || (block.number == finalized.number && block.hash != finalized.hash))
7456            {
7457                return Err(invalid(
7458                    "safe head cannot precede or conflict with finalized head".into(),
7459                ));
7460            }
7461            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7462        }
7463        ChainControl::Finalized(block) => {
7464            validate_sequence_known_identity(state, block, "finalized")?;
7465            validate_sequence_head_within_coverage(state, block, "finalized")?;
7466            if let Some(current) = state.finalized_head.as_ref()
7467                && (block.number < current.number
7468                    || (block.number == current.number
7469                        && (block.hash != current.hash
7470                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7471            {
7472                return Err(invalid(format!(
7473                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7474                    block.number, block.hash, current.number, current.hash
7475                )));
7476            }
7477            if let Some(safe) = state.safe_head.as_ref()
7478                && (block.number > safe.number
7479                    || (block.number == safe.number && block.hash != safe.hash))
7480            {
7481                return Err(invalid(
7482                    "finalized head cannot advance beyond or conflict with safe head".into(),
7483                ));
7484            }
7485            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7486        }
7487        ChainControl::CanonicalProgress(block)
7488        | ChainControl::Barrier {
7489            block: Some(block), ..
7490        } => {
7491            validate_sequence_known_identity(state, block, "canonical coverage")?;
7492            if let Some(current) = state.coverage_head.as_ref()
7493                && (block.number < current.number
7494                    || (block.number == current.number && block.hash != current.hash))
7495            {
7496                return Err(invalid(format!(
7497                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7498                    block.number, block.hash, current.number, current.hash
7499                )));
7500            }
7501            if let Some(current) = state.coverage_head.as_ref()
7502                && current.number.checked_add(1) == Some(block.number)
7503                && block.parent_hash.is_some()
7504                && block.parent_hash != Some(current.hash)
7505            {
7506                return Err(invalid(format!(
7507                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7508                    block.number, block.hash, current.number, current.hash
7509                )));
7510            }
7511        }
7512        ChainControl::Barrier { block: None, .. } => {}
7513        ChainControl::Reorg {
7514            common_ancestor,
7515            old_tip,
7516            new_tip,
7517        } => {
7518            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7519            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7520            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7521            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7522            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7523            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7524            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7525            if let Some(current) = state.coverage_head.as_ref()
7526                && (old_tip.number != current.number
7527                    || old_tip.hash != current.hash
7528                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7529            {
7530                return Err(invalid(format!(
7531                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7532                    old_tip.number, old_tip.hash, current.number, current.hash
7533                )));
7534            }
7535            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7536                return Err(invalid(
7537                    "reorg common ancestor cannot be above either branch tip".into(),
7538                ));
7539            }
7540            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7541            {
7542                return Err(invalid(
7543                    "reorg must replace non-empty old and new branches above the common ancestor"
7544                        .into(),
7545                ));
7546            }
7547            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7548                return Err(invalid(
7549                    "reorg old and new tips cannot have the same canonical identity".into(),
7550                ));
7551            }
7552            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7553                if common_ancestor.number.checked_add(1) == Some(tip.number)
7554                    && tip.parent_hash != Some(common_ancestor.hash)
7555                {
7556                    return Err(invalid(format!(
7557                        "reorg {label} tip does not descend from the common ancestor"
7558                    )));
7559                }
7560            }
7561            if let Some(finalized) = state.finalized_head.as_ref()
7562                && (common_ancestor.number < finalized.number
7563                    || (common_ancestor.number == finalized.number
7564                        && common_ancestor.hash != finalized.hash))
7565            {
7566                return Err(invalid(
7567                    "reorg would cross or conflict with the finalized head".into(),
7568                ));
7569            }
7570        }
7571    }
7572    Ok(())
7573}
7574
7575fn validate_sequence_known_identity(
7576    state: &CanonicalSequenceState,
7577    block: &BlockRef,
7578    label: &str,
7579) -> Result<(), ReactiveError> {
7580    validate_sequence_known_hash_height(state, block, label)?;
7581    validate_sequence_known_parent_height(state, block, label)?;
7582    let known = state
7583        .coverage_head
7584        .as_ref()
7585        .filter(|head| head.number == block.number)
7586        .or_else(|| {
7587            state
7588                .retained_canonical_history
7589                .iter()
7590                .find(|entry| entry.number == block.number)
7591        });
7592    if let Some(known) = known
7593        && !optional_block_refs_are_compatible(Some(known), Some(block))
7594    {
7595        return Err(ReactiveError::InvalidChainControl {
7596            message: format!(
7597                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7598                block.number, block.hash, known
7599            ),
7600        });
7601    }
7602    Ok(())
7603}
7604
7605fn validate_sequence_known_parent_height(
7606    state: &CanonicalSequenceState,
7607    block: &BlockRef,
7608    label: &str,
7609) -> Result<(), ReactiveError> {
7610    let Some(parent_hash) = block.parent_hash else {
7611        return Ok(());
7612    };
7613    let known_parent = state
7614        .retained_canonical_history
7615        .iter()
7616        .chain(state.coverage_head.iter())
7617        .chain(state.safe_head.iter())
7618        .chain(state.finalized_head.iter())
7619        .find(|known| known.hash == parent_hash);
7620    if let Some(parent) = known_parent
7621        && parent.number.checked_add(1) != Some(block.number)
7622    {
7623        return Err(ReactiveError::InvalidChainControl {
7624            message: format!(
7625                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7626                block.number, block.hash, parent.hash, parent.number
7627            ),
7628        });
7629    }
7630    Ok(())
7631}
7632
7633fn validate_sequence_head_within_coverage(
7634    state: &CanonicalSequenceState,
7635    block: &BlockRef,
7636    label: &str,
7637) -> Result<(), ReactiveError> {
7638    let Some(coverage) = state.coverage_head.as_ref() else {
7639        return Err(ReactiveError::InvalidChainControl {
7640            message: format!("{label} head requires an authoritative coverage head"),
7641        });
7642    };
7643    if block.number > coverage.number
7644        || (block.number == coverage.number
7645            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7646    {
7647        return Err(ReactiveError::InvalidChainControl {
7648            message: format!(
7649                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7650                block.number, block.hash, coverage.number, coverage.hash
7651            ),
7652        });
7653    }
7654    Ok(())
7655}
7656
7657fn validate_sequence_matching_metadata(
7658    state: &CanonicalSequenceState,
7659    block: &BlockRef,
7660    label: &str,
7661) -> Result<(), ReactiveError> {
7662    validate_sequence_known_hash_height(state, block, label)?;
7663    validate_sequence_known_parent_height(state, block, label)?;
7664    let known = state
7665        .coverage_head
7666        .as_ref()
7667        .filter(|head| head.number == block.number && head.hash == block.hash)
7668        .or_else(|| {
7669            state
7670                .retained_canonical_history
7671                .iter()
7672                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7673        });
7674    if let Some(known) = known
7675        && !optional_block_refs_are_compatible(Some(known), Some(block))
7676    {
7677        return Err(ReactiveError::InvalidChainControl {
7678            message: format!(
7679                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7680                block.number, block.hash, known
7681            ),
7682        });
7683    }
7684    Ok(())
7685}
7686
7687fn validate_sequence_known_hash_height(
7688    state: &CanonicalSequenceState,
7689    block: &BlockRef,
7690    label: &str,
7691) -> Result<(), ReactiveError> {
7692    let known = state
7693        .retained_canonical_history
7694        .iter()
7695        .chain(state.coverage_head.iter())
7696        .chain(state.safe_head.iter())
7697        .chain(state.finalized_head.iter())
7698        .find(|known| known.hash == block.hash);
7699    if let Some(known) = known
7700        && known.number != block.number
7701    {
7702        return Err(ReactiveError::InvalidChainControl {
7703            message: format!(
7704                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7705                block.number, block.hash, known.number
7706            ),
7707        });
7708    }
7709    Ok(())
7710}
7711
7712fn validate_reorg_ancestor_against_retained_branch(
7713    state: &CanonicalSequenceState,
7714    ancestor: &BlockRef,
7715) -> Result<(), ReactiveError> {
7716    let adjacent_number = ancestor.number.checked_add(1);
7717    for retained in state
7718        .retained_canonical_history
7719        .iter()
7720        .chain(state.coverage_head.iter())
7721        .chain(state.safe_head.iter())
7722        .chain(state.finalized_head.iter())
7723    {
7724        if Some(retained.number) == adjacent_number
7725            && retained
7726                .parent_hash
7727                .is_some_and(|parent| parent != ancestor.hash)
7728        {
7729            return Err(ReactiveError::InvalidChainControl {
7730                message: format!(
7731                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7732                    ancestor.number,
7733                    ancestor.hash,
7734                    retained.number,
7735                    retained.hash,
7736                    retained.parent_hash
7737                ),
7738            });
7739        }
7740        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7741            return Err(ReactiveError::InvalidChainControl {
7742                message: format!(
7743                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7744                    ancestor.number, ancestor.hash, retained.number, retained.hash
7745                ),
7746            });
7747        }
7748    }
7749    Ok(())
7750}
7751
7752fn validate_sequence_adjacent_parent_identity(
7753    state: &CanonicalSequenceState,
7754    block: &BlockRef,
7755    label: &str,
7756) -> Result<(), ReactiveError> {
7757    let Some(parent_hash) = block.parent_hash else {
7758        return Ok(());
7759    };
7760    let Some(parent_number) = block.number.checked_sub(1) else {
7761        return Ok(());
7762    };
7763    let known_parent = state
7764        .retained_canonical_history
7765        .iter()
7766        .chain(state.coverage_head.iter())
7767        .chain(state.safe_head.iter())
7768        .chain(state.finalized_head.iter())
7769        .find(|known| known.number == parent_number);
7770    if let Some(known_parent) = known_parent
7771        && known_parent.hash != parent_hash
7772    {
7773        return Err(ReactiveError::InvalidChainControl {
7774            message: format!(
7775                "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7776                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7777            ),
7778        });
7779    }
7780    Ok(())
7781}
7782
7783fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7784    state
7785        .coverage_head
7786        .iter()
7787        .chain(state.retained_canonical_history.iter())
7788        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7789        .any(|known| {
7790            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7791                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7792        })
7793}
7794
7795fn validate_sequence_implicit_finality<N: Network>(
7796    state: &CanonicalSequenceState,
7797    record: &ReactiveInputRecord<N>,
7798    resolved_canonical_block: Option<&BlockRef>,
7799) -> Result<(), ReactiveError> {
7800    let Some(finalized) = state.finalized_head.as_ref() else {
7801        return Ok(());
7802    };
7803    if let Some((dropped, _)) = reorg_signal_block(record) {
7804        if dropped.number <= finalized.number {
7805            return Err(ReactiveError::InvalidChainControl {
7806                message: format!(
7807                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7808                    dropped.number, dropped.hash, finalized.number, finalized.hash
7809                ),
7810            });
7811        }
7812        return Ok(());
7813    }
7814    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7815        return Ok(());
7816    };
7817    let Some(latest) = state.coverage_head.as_ref() else {
7818        return Ok(());
7819    };
7820    if (block.number == latest.number && block.hash == latest.hash)
7821        || state
7822            .retained_canonical_history
7823            .iter()
7824            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7825        || (latest.number.checked_add(1) == Some(block.number)
7826            && block.parent_hash == Some(latest.hash))
7827        || latest
7828            .number
7829            .checked_add(1)
7830            .is_some_and(|next| block.number > next)
7831    {
7832        return Ok(());
7833    }
7834    let crosses_finalized = if block.number <= finalized.number {
7835        true
7836    } else if let Some(parent_hash) = block.parent_hash {
7837        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7838            false
7839        } else if let Some(parent_index) =
7840            state.retained_canonical_history.iter().rposition(|entry| {
7841                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7842            })
7843        {
7844            state
7845                .retained_canonical_history
7846                .iter()
7847                .skip(parent_index + 1)
7848                .any(|entry| entry.number <= finalized.number)
7849        } else {
7850            true
7851        }
7852    } else {
7853        true
7854    };
7855    if crosses_finalized {
7856        return Err(ReactiveError::InvalidChainControl {
7857            message: format!(
7858                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7859                block.number, block.hash, finalized.number, finalized.hash
7860            ),
7861        });
7862    }
7863    Ok(())
7864}
7865
7866fn validate_required_reorg_anchor(
7867    required: Option<RequiredReorgAnchor>,
7868    block: &BlockRef,
7869) -> Result<(), ReactiveError> {
7870    let Some(required) = required else {
7871        return Ok(());
7872    };
7873    let ancestor_hash = required.hash();
7874    let restores_ancestor =
7875        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
7876    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
7877        && ancestor_hash.is_some()
7878        && (block.parent_hash == ancestor_hash
7879            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
7880    if restores_ancestor || replaces_removed_child {
7881        return Ok(());
7882    }
7883    Err(ReactiveError::InvalidChainControl {
7884        message: format!(
7885            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
7886            block.number, block.hash, required.number
7887        ),
7888    })
7889}
7890
7891fn validate_replacement_reorg_anchor(
7892    required: Option<RequiredReorgAnchor>,
7893    block: &BlockRef,
7894    policy: CanonicalSequenceValidationPolicy,
7895    oldest_retained: Option<u64>,
7896) -> Result<bool, CanonicalSequenceError> {
7897    let Some(required) = required else {
7898        return Ok(false);
7899    };
7900    match validate_required_reorg_anchor(Some(required), block) {
7901        Ok(()) => Ok(true),
7902        Err(error) if required.block.is_some() => Err(error.into()),
7903        Err(_) if policy.requires_complete_rollback() => {
7904            Err(CanonicalSequenceError::IncompleteRollback {
7905                common_ancestor: required.number,
7906                oldest_retained,
7907                kind: CanonicalRollbackKind::MissingReplacement,
7908            })
7909        }
7910        Err(_) => Ok(false),
7911    }
7912}
7913
7914fn apply_sequence_canonical_block(
7915    state: &mut CanonicalSequenceState,
7916    block: &BlockRef,
7917    allow_parentless_adjacent_extension: bool,
7918) -> Result<Option<SequenceRewind>, ReactiveError> {
7919    let latest = state.coverage_head;
7920    let already_known = state
7921        .retained_canonical_history
7922        .iter()
7923        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7924    let repeats_tip =
7925        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
7926    let extends_tip = latest.is_some_and(|head| {
7927        head.number.checked_add(1) == Some(block.number)
7928            && (block.parent_hash == Some(head.hash)
7929                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
7930    });
7931    let forward_gap = latest.is_some_and(|head| {
7932        head.number
7933            .checked_add(1)
7934            .is_some_and(|next| block.number > next)
7935    });
7936    let mut rewind = None;
7937
7938    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
7939        let retained_parent = block.parent_hash.and_then(|parent_hash| {
7940            state
7941                .retained_canonical_history
7942                .iter()
7943                .rposition(|entry| {
7944                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7945                })
7946                .map(|index| (index, state.retained_canonical_history[index]))
7947        });
7948        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
7949            state.finalized_head.filter(|finalized| {
7950                finalized.number.checked_add(1) == Some(block.number)
7951                    && finalized.hash == parent_hash
7952            })
7953        });
7954        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
7955            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
7956            (Some(parent), dropped)
7957        } else if let Some(finalized) = finalized_parent {
7958            let dropped = state
7959                .retained_canonical_history
7960                .iter()
7961                .position(|entry| entry.number > finalized.number)
7962                .map_or_else(Vec::new, |index| {
7963                    state.retained_canonical_history.split_off(index)
7964                });
7965            (Some(finalized), dropped)
7966        } else {
7967            // The observable runtime policy may continue after an incomplete
7968            // rollback proof so it can degrade health and repair. The metadata
7969            // validator must nevertheless avoid claiming any old prefix is an
7970            // ancestor of the arriving branch: without the exact N-1 parent,
7971            // no retained identity is authenticated.
7972            (None, std::mem::take(&mut state.retained_canonical_history))
7973        };
7974        state.coverage_head = common_ancestor;
7975        if let Some(common_ancestor) = common_ancestor {
7976            clear_sequence_heads_above(state, &common_ancestor);
7977        } else {
7978            state.safe_head = None;
7979            state.finalized_head = None;
7980        }
7981        rewind = Some(SequenceRewind {
7982            common_ancestor,
7983            dropped,
7984        });
7985    }
7986    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
7987    advance_or_enrich_coverage(&mut state.coverage_head, block);
7988    Ok(rewind)
7989}
7990
7991fn sequence_implicit_replacement_requires_history(
7992    state: &CanonicalSequenceState,
7993    block: &BlockRef,
7994    policy: CanonicalSequenceValidationPolicy,
7995) -> Result<bool, ReactiveError> {
7996    let Some(latest) = state.coverage_head else {
7997        return Ok(false);
7998    };
7999    let already_known = state
8000        .retained_canonical_history
8001        .iter()
8002        .any(|entry| entry.number == block.number && entry.hash == block.hash);
8003    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
8004    let extends_tip = latest.number.checked_add(1) == Some(block.number)
8005        && block.parent_hash == Some(latest.hash);
8006    let forward_gap = latest
8007        .number
8008        .checked_add(1)
8009        .is_some_and(|next| block.number > next);
8010    if already_known || repeats_tip || extends_tip || forward_gap {
8011        return Ok(false);
8012    }
8013    let Some(parent_hash) = block.parent_hash else {
8014        if policy.requires_complete_rollback() {
8015            return Err(ReactiveError::InvalidChainControl {
8016                message: format!(
8017                    "implicit canonical replacement {}:{:?} must identify its parent",
8018                    block.number, block.hash
8019                ),
8020            });
8021        }
8022        return Ok(true);
8023    };
8024    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
8025        state
8026            .retained_canonical_history
8027            .iter()
8028            .chain(state.coverage_head.iter())
8029            .chain(state.safe_head.iter())
8030            .chain(state.finalized_head.iter())
8031            .find(|known| known.number == parent_number)
8032    });
8033    if let Some(known_parent) = known_adjacent_parent
8034        && known_parent.hash != parent_hash
8035        && policy.requires_complete_rollback()
8036    {
8037        return Err(ReactiveError::InvalidChainControl {
8038            message: format!(
8039                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
8040                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
8041            ),
8042        });
8043    }
8044    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
8045        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
8046    });
8047    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
8048        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
8049    });
8050    Ok(!retained_parent && !finalized_parent)
8051}
8052
8053fn upsert_sequence_history(
8054    history: &mut Vec<BlockRef>,
8055    block: &BlockRef,
8056) -> Result<(), ReactiveError> {
8057    if let Some(existing) = history
8058        .iter_mut()
8059        .find(|entry| entry.number == block.number)
8060    {
8061        if existing.hash != block.hash {
8062            return Err(ReactiveError::InvalidChainControl {
8063                message: format!(
8064                    "canonical block {}:{:?} conflicts with retained identity {:?}",
8065                    block.number, block.hash, existing
8066                ),
8067            });
8068        }
8069        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
8070            return Err(ReactiveError::InvalidChainControl {
8071                message: format!(
8072                    "canonical block {}:{:?} carries conflicting retained metadata",
8073                    block.number, block.hash
8074                ),
8075            });
8076        }
8077        enrich_block_ref(existing, block);
8078    } else {
8079        history.push(*block);
8080        history.sort_by_key(|entry| entry.number);
8081    }
8082    Ok(())
8083}
8084
8085fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
8086    if state.safe_head.as_ref().is_some_and(|head| {
8087        head.number > ancestor.number
8088            || (head.number == ancestor.number && head.hash != ancestor.hash)
8089    }) {
8090        state.safe_head = None;
8091    }
8092    if state.finalized_head.as_ref().is_some_and(|head| {
8093        head.number > ancestor.number
8094            || (head.number == ancestor.number && head.hash != ancestor.hash)
8095    }) {
8096        state.finalized_head = None;
8097    }
8098}
8099
8100fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
8101    let split = controls
8102        .iter()
8103        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
8104        .unwrap_or(controls.len());
8105    if controls[split..]
8106        .iter()
8107        .any(|control| matches!(control, ChainControl::Reorg { .. }))
8108    {
8109        return Err(ReactiveError::InvalidChainControl {
8110            message: "reorg controls must precede records and all post-record controls in a batch"
8111                .into(),
8112        });
8113    }
8114    Ok(split)
8115}
8116
8117fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
8118    match control {
8119        ChainControl::CanonicalProgress(block)
8120        | ChainControl::Barrier {
8121            block: Some(block), ..
8122        } => Some(block),
8123        ChainControl::Reorg { .. }
8124        | ChainControl::Safe(_)
8125        | ChainControl::Finalized(_)
8126        | ChainControl::Barrier { block: None, .. } => None,
8127    }
8128}
8129
8130fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
8131    match control {
8132        ChainControl::Safe(block)
8133        | ChainControl::Finalized(block)
8134        | ChainControl::CanonicalProgress(block)
8135        | ChainControl::Barrier {
8136            block: Some(block), ..
8137        } => Some(block),
8138        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
8139    }
8140}
8141
8142fn assert_chain_control_identities(
8143    asserted_blocks: &mut HashMap<u64, BlockRef>,
8144    control: &ChainControl,
8145) -> Result<(), ReactiveError> {
8146    match control {
8147        ChainControl::Safe(block)
8148        | ChainControl::Finalized(block)
8149        | ChainControl::CanonicalProgress(block)
8150        | ChainControl::Barrier {
8151            block: Some(block), ..
8152        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
8153        ChainControl::Barrier { block: None, .. } => Ok(()),
8154        ChainControl::Reorg {
8155            common_ancestor,
8156            new_tip,
8157            ..
8158        } => {
8159            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
8160            assert_canonical_block_identity(
8161                asserted_blocks,
8162                common_ancestor,
8163                "reorg common ancestor",
8164            )?;
8165            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
8166        }
8167    }
8168}
8169
8170fn assert_canonical_block_identity(
8171    asserted_blocks: &mut HashMap<u64, BlockRef>,
8172    block: &BlockRef,
8173    label: &str,
8174) -> Result<(), ReactiveError> {
8175    for asserted in asserted_blocks.values() {
8176        if asserted.hash == block.hash && asserted.number != block.number {
8177            return Err(ReactiveError::InvalidChainControl {
8178                message: format!(
8179                    "{label} hash {:?} is already asserted at height {}, not {}",
8180                    block.hash, asserted.number, block.number
8181                ),
8182            });
8183        }
8184        if block
8185            .parent_hash
8186            .is_some_and(|parent| parent == asserted.hash)
8187            && asserted.number.checked_add(1) != Some(block.number)
8188        {
8189            return Err(ReactiveError::InvalidChainControl {
8190                message: format!(
8191                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
8192                    block.number, block.hash, asserted.hash, asserted.number
8193                ),
8194            });
8195        }
8196        if asserted
8197            .parent_hash
8198            .is_some_and(|parent| parent == block.hash)
8199            && block.number.checked_add(1) != Some(asserted.number)
8200        {
8201            return Err(ReactiveError::InvalidChainControl {
8202                message: format!(
8203                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
8204                    asserted.number, asserted.hash, block.hash, block.number
8205                ),
8206            });
8207        }
8208    }
8209    if let Some(known) = asserted_blocks.get_mut(&block.number) {
8210        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
8211            return Err(ReactiveError::InvalidChainControl {
8212                message: format!(
8213                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
8214                    block.number, block.hash, known
8215                ),
8216            });
8217        }
8218        enrich_block_ref(known, block);
8219    } else {
8220        asserted_blocks.insert(block.number, *block);
8221    }
8222    Ok(())
8223}
8224
8225fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8226    match current {
8227        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8228            enrich_block_ref(current, incoming);
8229        }
8230        _ => *current = Some(*incoming),
8231    }
8232}
8233
8234fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8235    match current {
8236        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8237            enrich_block_ref(current, incoming);
8238        }
8239        Some(current) if current.number >= incoming.number => {}
8240        _ => *current = Some(*incoming),
8241    }
8242}
8243
8244fn validate_adjacent_finality(
8245    finalized: Option<&BlockRef>,
8246    safe: Option<&BlockRef>,
8247) -> Result<(), ReactiveError> {
8248    let Some((finalized, safe)) = finalized.zip(safe) else {
8249        return Ok(());
8250    };
8251    if finalized.number.checked_add(1) == Some(safe.number)
8252        && safe.parent_hash != Some(finalized.hash)
8253    {
8254        return Err(ReactiveError::InvalidChainControl {
8255            message: "adjacent safe head does not descend from finalized head".into(),
8256        });
8257    }
8258    Ok(())
8259}
8260
8261/// Fold every address a [`StateDiff`] references — genuine changes
8262/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
8263/// into `into`. Used by the per-block root gate to accumulate the batch's
8264/// decoder-touched address set: an account a decoder wrote (or tried to write) is
8265/// "covered," so a subsequent root move for it is not a coverage gap.
8266fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
8267    into.extend(diff.slots.iter().map(|change| change.address));
8268    into.extend(diff.accounts.iter().map(|change| change.address));
8269    into.extend(diff.purged.iter().map(|purge| purge.address));
8270    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
8271    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
8272    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
8273    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
8274}
8275
8276/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
8277/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
8278/// existing account-resync path (Wave 2). The id is derived from the address and
8279/// block so a repeated move on the same account/block coalesces deterministically.
8280fn root_moved_account_resync(
8281    address: Address,
8282    block: u64,
8283    fields: AccountFieldMask,
8284) -> ResyncRequest {
8285    ResyncRequest {
8286        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
8287        reason: ResyncReason::RootMoved,
8288        block: ResyncBlock::Number(block),
8289        targets: vec![ResyncTarget::Account { address, fields }],
8290        priority: ResyncPriority::Normal,
8291    }
8292}
8293
8294fn batch_preconfirmation<N: Network>(
8295    batch: &ReactiveInputBatch<N>,
8296) -> Result<Option<FlashblockRef>, ReactiveError> {
8297    let mut flashblock: Option<FlashblockRef> = None;
8298    let mut has_non_preconfirmed = false;
8299    for (index, record) in batch.records().iter().enumerate() {
8300        match &record.context.chain_status {
8301            ChainStatus::Preconfirmed {
8302                flashblock: current,
8303            } => {
8304                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
8305                    return Err(ReactiveError::InvalidInputRecord {
8306                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
8307                    });
8308                }
8309                if flashblock
8310                    .as_ref()
8311                    .is_some_and(|known| known != current.as_ref())
8312                {
8313                    return Err(ReactiveError::InvalidInputRecord {
8314                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
8315                    });
8316                }
8317                flashblock.get_or_insert_with(|| current.as_ref().clone());
8318            }
8319            _ => has_non_preconfirmed = true,
8320        }
8321    }
8322    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
8323        return Err(ReactiveError::InvalidInputRecord {
8324            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
8325        });
8326    }
8327    Ok(flashblock)
8328}
8329
8330fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
8331    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8332        return None;
8333    }
8334    if is_canonical_status(&record.context.chain_status) {
8335        return context_block_ref(&record.context);
8336    }
8337    None
8338}
8339
8340fn resolve_record_block_payload_metadata<N: Network>(
8341    record: &ReactiveInputRecord<N>,
8342    mut block: BlockRef,
8343) -> Result<BlockRef, ReactiveError> {
8344    let ReactiveInput::Log(log) = &record.input else {
8345        return Ok(block);
8346    };
8347    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
8348        return Err(ReactiveError::InvalidInputRecord {
8349            message: "log payload and canonical context carry different block identities".into(),
8350        });
8351    }
8352    if let Some(timestamp) = log.block_timestamp {
8353        if block.timestamp.is_some_and(|known| known != timestamp) {
8354            return Err(ReactiveError::InvalidInputRecord {
8355                message: "log payload and canonical context carry different block timestamps"
8356                    .into(),
8357            });
8358        }
8359        block.timestamp = Some(timestamp);
8360    }
8361    Ok(block)
8362}
8363
8364fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
8365    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
8366    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
8367        && record.context.block != Some(flashblock.block_ref())
8368    {
8369        return Err(invalid(
8370            "pre-confirmed status and context carry different partial block identities".into(),
8371        ));
8372    }
8373    let status_block = match &record.context.chain_status {
8374        ChainStatus::Included { block, .. }
8375        | ChainStatus::Safe { block }
8376        | ChainStatus::Finalized { block }
8377        | ChainStatus::Reorged {
8378            dropped_from: block,
8379        } => Some(block),
8380        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
8381        ChainStatus::Pending => None,
8382    };
8383    match (status_block, record.context.block.as_ref()) {
8384        (Some(status), Some(context)) if status == context => {}
8385        (Some(_), Some(_)) => {
8386            return Err(invalid(
8387                "chain status and context carry different block identities".into(),
8388            ));
8389        }
8390        (Some(_), None) => {
8391            return Err(invalid(
8392                "included or reorged input is missing its context block".into(),
8393            ));
8394        }
8395        (None, Some(_)) => {
8396            return Err(invalid(
8397                "pending input cannot carry a canonical context block".into(),
8398            ));
8399        }
8400        (None, None) => {}
8401    }
8402
8403    match &record.input {
8404        ReactiveInput::Log(log) => {
8405            let Some(block) = status_block else {
8406                return Err(invalid(
8407                    "log input must carry an included or reorged block identity".into(),
8408                ));
8409            };
8410            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
8411                return Err(invalid(
8412                    "removed log must carry reorged chain status".into(),
8413                ));
8414            }
8415            let block_number = log
8416                .block_number
8417                .ok_or_else(|| invalid("log is missing its block number".into()))?;
8418            let block_hash = log
8419                .block_hash
8420                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
8421            log.transaction_hash
8422                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
8423            let transaction_index = log
8424                .transaction_index
8425                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
8426            let log_index = log
8427                .log_index
8428                .ok_or_else(|| invalid("log is missing its log index".into()))?;
8429            if block_number != block.number
8430                || block_hash != block.hash
8431                || !optional_metadata_compatible(
8432                    log.block_timestamp.as_ref(),
8433                    block.timestamp.as_ref(),
8434                )
8435            {
8436                return Err(invalid(
8437                    "log payload and context carry different block identities".into(),
8438                ));
8439            }
8440            if record.context.transaction_index != Some(transaction_index)
8441                || record.context.log_index != Some(log_index)
8442            {
8443                return Err(invalid(
8444                    "log payload and context carry different transaction/log positions".into(),
8445                ));
8446            }
8447        }
8448        ReactiveInput::BlockHeader(header) => {
8449            if let Some(block) = status_block {
8450                if header.number() != block.number
8451                    || header.hash() != block.hash
8452                    || Some(header.parent_hash()) != block.parent_hash
8453                    || Some(header.timestamp()) != block.timestamp
8454                {
8455                    return Err(invalid(
8456                        "block header payload and context carry different block identities".into(),
8457                    ));
8458                }
8459            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8460                return Err(invalid("block header has an unsupported lifecycle".into()));
8461            }
8462            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8463                return Err(invalid(
8464                    "block header context cannot carry transaction/log positions".into(),
8465                ));
8466            }
8467        }
8468        ReactiveInput::FullBlock(block_response) => {
8469            let header = block_response.header();
8470            if let Some(block) = status_block {
8471                if header.number() != block.number
8472                    || header.hash() != block.hash
8473                    || Some(header.parent_hash()) != block.parent_hash
8474                    || Some(header.timestamp()) != block.timestamp
8475                {
8476                    return Err(invalid(
8477                        "full-block payload and context carry different block identities".into(),
8478                    ));
8479                }
8480            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8481                return Err(invalid("full block has an unsupported lifecycle".into()));
8482            }
8483            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8484                return Err(invalid(
8485                    "full-block context cannot carry transaction/log positions".into(),
8486                ));
8487            }
8488            if let Some(transactions) = block_response.transactions().as_transactions() {
8489                for (index, transaction) in transactions.iter().enumerate() {
8490                    if transaction
8491                        .block_hash()
8492                        .is_some_and(|hash| hash != header.hash())
8493                        || transaction
8494                            .block_number()
8495                            .is_some_and(|number| number != header.number())
8496                        || transaction
8497                            .transaction_index()
8498                            .is_some_and(|position| position != index as u64)
8499                    {
8500                        return Err(invalid(format!(
8501                            "full-block transaction {index} carries contradictory inclusion metadata"
8502                        )));
8503                    }
8504                    if transaction
8505                        .chain_id()
8506                        .zip(record.context.chain_id)
8507                        .is_some_and(|(transaction, context)| transaction != context)
8508                    {
8509                        return Err(invalid(format!(
8510                            "full-block transaction {index} carries a chain id conflicting with its context"
8511                        )));
8512                    }
8513                }
8514            }
8515        }
8516        ReactiveInput::PendingTxHash(_) => {
8517            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8518                return Err(invalid(
8519                    "pending transaction input must carry pending chain status".into(),
8520                ));
8521            }
8522            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8523                return Err(invalid(
8524                    "pending transaction context cannot carry canonical positions".into(),
8525                ));
8526            }
8527        }
8528        ReactiveInput::PendingTx(transaction) => {
8529            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8530                return Err(invalid(
8531                    "pending transaction input must carry pending chain status".into(),
8532                ));
8533            }
8534            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8535                return Err(invalid(
8536                    "pending transaction context cannot carry canonical positions".into(),
8537                ));
8538            }
8539            if transaction.block_hash().is_some()
8540                || transaction.block_number().is_some()
8541                || transaction.transaction_index().is_some()
8542            {
8543                return Err(invalid(
8544                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8545                ));
8546            }
8547            if transaction
8548                .chain_id()
8549                .zip(record.context.chain_id)
8550                .is_some_and(|(transaction, context)| transaction != context)
8551            {
8552                return Err(invalid(
8553                    "pending transaction carries a chain id conflicting with its context".into(),
8554                ));
8555            }
8556        }
8557    }
8558    Ok(())
8559}
8560
8561/// Best-effort per-block env refresh (Phase-8 step 2).
8562///
8563/// For a canonical record carrying a full header — a
8564/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8565/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8566/// `Some(result)` when a header was present (so the caller can surface a strict
8567/// validation error), and `None` for pending/reorged records or non-header
8568/// inputs, which must never drive a canonical env refresh.
8569fn advance_block_for_canonical_record<N: Network>(
8570    cache: &mut EvmCache,
8571    record: &ReactiveInputRecord<N>,
8572) -> Option<Result<(), BlockContextError>> {
8573    if !is_canonical_status(&record.context.chain_status) {
8574        return None;
8575    }
8576    match &record.input {
8577        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8578        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8579        _ => None,
8580    }
8581}
8582
8583fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8584    match &ctx.chain_status {
8585        ChainStatus::Included { block, .. }
8586        | ChainStatus::Safe { block }
8587        | ChainStatus::Finalized { block } => Some(block),
8588        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8589        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8590        ChainStatus::Pending => ctx.block.as_ref(),
8591    }
8592}
8593
8594fn reorg_signal_block<N: Network>(
8595    record: &ReactiveInputRecord<N>,
8596) -> Option<(BlockRef, ReorgReason)> {
8597    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8598        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8599    }
8600
8601    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8602        return Some((*dropped_from, ReorgReason::ReorgedInput));
8603    }
8604
8605    None
8606}
8607
8608fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8609    context_block_ref(&record.context)
8610        .cloned()
8611        .or_else(|| match &record.input {
8612            ReactiveInput::Log(log) => Some(BlockRef {
8613                number: log.block_number?,
8614                hash: log.block_hash?,
8615                parent_hash: None,
8616                timestamp: log.block_timestamp,
8617            }),
8618            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8619                number: header.number(),
8620                hash: header.hash(),
8621                parent_hash: Some(header.parent_hash()),
8622                timestamp: Some(header.timestamp()),
8623            }),
8624            ReactiveInput::FullBlock(block) => {
8625                let header = block.header();
8626                Some(BlockRef {
8627                    number: header.number(),
8628                    hash: header.hash(),
8629                    parent_hash: Some(header.parent_hash()),
8630                    timestamp: Some(header.timestamp()),
8631                })
8632            }
8633            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8634        })
8635}
8636
8637fn remove_canceled_resyncs_from_batch(
8638    resyncs: &mut Vec<ResyncRequest>,
8639    canceled: &[ResyncRequest],
8640) {
8641    if canceled.is_empty() {
8642        return;
8643    }
8644    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8645    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8646}
8647
8648fn resync_target_address(target: &ResyncTarget) -> Address {
8649    match target {
8650        ResyncTarget::StorageSlot { address, .. }
8651        | ResyncTarget::StorageSlots { address, .. }
8652        | ResyncTarget::Account { address, .. } => *address,
8653    }
8654}
8655
8656fn resync_request_targets_dropped_block(
8657    request: &ResyncRequest,
8658    dropped_blocks: &[BlockRef],
8659) -> bool {
8660    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8661        return false;
8662    };
8663    dropped_blocks
8664        .iter()
8665        .any(|block| block.hash == *hash && block.number == *number)
8666}
8667
8668fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8669    let first = report.requested.first()?.block.clone();
8670    if !report
8671        .requested
8672        .iter()
8673        .all(|request| request.block == first)
8674    {
8675        return None;
8676    }
8677
8678    let ResyncBlock::Hash { number, hash, .. } = first else {
8679        return None;
8680    };
8681
8682    Some(BlockRef {
8683        number,
8684        hash,
8685        parent_hash: None,
8686        timestamp: None,
8687    })
8688}
8689
8690fn purge_scopes_for_dropped_journals<N: Network>(
8691    dropped: &[BlockJournal<N>],
8692) -> Vec<(Address, PurgeScope)> {
8693    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8694    for entry in dropped.iter().rev() {
8695        for diff in entry.rollback_diffs.iter().rev() {
8696            merge_purge_scopes_for_diff(&mut scopes, diff);
8697        }
8698    }
8699    scopes
8700}
8701
8702fn rollback_updates_for_dropped_journals<N: Network>(
8703    dropped: &[BlockJournal<N>],
8704    purge_scopes: &[(Address, PurgeScope)],
8705) -> Vec<StateUpdate> {
8706    let purge_addresses: HashSet<_> = purge_scopes
8707        .iter()
8708        .map(|(address, _scope)| *address)
8709        .collect();
8710    let mut updates = Vec::new();
8711    for entry in dropped.iter().rev() {
8712        for diff in entry.rollback_diffs.iter().rev() {
8713            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8714        }
8715    }
8716    updates
8717}
8718
8719fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8720    for change in &diff.accounts {
8721        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8722    }
8723    for record in &diff.purged {
8724        merge_purge_scope(scopes, record.address, record.scope.clone());
8725    }
8726}
8727
8728fn push_rollback_updates_for_diff(
8729    updates: &mut Vec<StateUpdate>,
8730    diff: &StateDiff,
8731    purge_addresses: &HashSet<Address>,
8732) {
8733    for change in diff.slots.iter().rev() {
8734        if purge_addresses.contains(&change.address) {
8735            continue;
8736        }
8737        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8738    }
8739}
8740
8741fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8742    if let Some((_existing_address, existing_scope)) = scopes
8743        .iter_mut()
8744        .find(|(existing_address, _scope)| *existing_address == address)
8745    {
8746        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8747    } else {
8748        scopes.push((address, scope));
8749    }
8750}
8751
8752fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8753    match (left, right) {
8754        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8755        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8756        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8757            for slot in right {
8758                if !left.contains(&slot) {
8759                    left.push(slot);
8760                }
8761            }
8762            PurgeScope::Slots(left)
8763        }
8764    }
8765}
8766
8767#[derive(Clone, Debug)]
8768struct StorageFetchSlot {
8769    address: Address,
8770    slot: U256,
8771    origins: Vec<StorageFetchOrigin>,
8772}
8773
8774#[derive(Clone, Debug)]
8775struct StorageFetchOrigin {
8776    request_id: ResyncId,
8777    target: ResyncTarget,
8778}
8779
8780#[derive(Clone, Debug)]
8781struct StorageFetchGroup {
8782    block: ResyncBlock,
8783    slots: Vec<StorageFetchSlot>,
8784    seen: HashSet<(Address, U256)>,
8785}
8786
8787/// One account-target resync collected during request scanning, resolved through
8788/// the account proof fetcher after storage groups are processed.
8789#[derive(Clone, Debug)]
8790struct AccountResyncTarget {
8791    request_id: ResyncId,
8792    block: ResyncBlock,
8793    address: Address,
8794    fields: AccountFieldMask,
8795}
8796
8797fn resolve_trace_resyncs(
8798    cache: &EvmCache,
8799    storage_groups: &mut Vec<StorageFetchGroup>,
8800    account_targets: &mut Vec<AccountResyncTarget>,
8801    state_updates: &mut Vec<StateUpdate>,
8802) {
8803    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8804        return;
8805    };
8806
8807    let mut blocks = Vec::new();
8808    let mut seen = HashSet::new();
8809    for block in storage_groups
8810        .iter()
8811        .map(|group| group.block.clone())
8812        .chain(account_targets.iter().map(|target| target.block.clone()))
8813    {
8814        if seen.insert(block.clone()) {
8815            blocks.push(block);
8816        }
8817    }
8818
8819    let mut traces = HashMap::new();
8820    for block in blocks {
8821        match (fetcher)(resync_block_to_block_id(&block)) {
8822            Ok(diff) => {
8823                traces.insert(block, diff);
8824            }
8825            Err(error) => {
8826                tracing::debug!(
8827                    block = ?block,
8828                    error = %error,
8829                    "block trace resync source failed; falling back to point resync"
8830                );
8831            }
8832        }
8833    }
8834
8835    for group in storage_groups.iter_mut() {
8836        let Some(trace) = traces.get(&group.block) else {
8837            continue;
8838        };
8839        group.slots.retain(|slot| {
8840            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8841                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8842                return false;
8843            }
8844            cache
8845                .cached_storage_value(slot.address, slot.slot)
8846                .is_none()
8847        });
8848        group.seen = group
8849            .slots
8850            .iter()
8851            .map(|slot| (slot.address, slot.slot))
8852            .collect();
8853    }
8854    storage_groups.retain(|group| !group.slots.is_empty());
8855
8856    let mut unresolved_accounts = Vec::new();
8857    for mut account in account_targets.drain(..) {
8858        let Some(trace) = traces.get(&account.block) else {
8859            unresolved_accounts.push(account);
8860            continue;
8861        };
8862        let Some(trace_account) = trace
8863            .accounts
8864            .iter()
8865            .find(|diff| diff.address == account.address)
8866        else {
8867            unresolved_accounts.push(account);
8868            continue;
8869        };
8870
8871        let mut patch = AccountPatch::default();
8872        let mut unresolved = AccountFieldMask::default();
8873        if account.fields.balance {
8874            if let Some(balance) = trace_account.balance {
8875                patch = patch.balance(balance);
8876            } else {
8877                unresolved.balance = true;
8878            }
8879        }
8880        if account.fields.nonce {
8881            if let Some(nonce) = trace_account.nonce {
8882                patch = patch.nonce(nonce);
8883            } else {
8884                unresolved.nonce = true;
8885            }
8886        }
8887        if account.fields.code {
8888            if let Some(code) = &trace_account.code {
8889                patch = patch.code(code.clone());
8890            } else {
8891                unresolved.code = true;
8892            }
8893        }
8894
8895        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
8896            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8897        }
8898        if !account_field_mask_empty(unresolved) {
8899            account.fields = unresolved;
8900            unresolved_accounts.push(account);
8901        }
8902    }
8903    *account_targets = unresolved_accounts;
8904}
8905
8906fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
8907    trace
8908        .accounts
8909        .iter()
8910        .find(|account| account.address == address)
8911        .and_then(|account| {
8912            account
8913                .storage
8914                .iter()
8915                .find(|entry| entry.slot == slot)
8916                .map(|entry| entry.value)
8917        })
8918}
8919
8920fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
8921    !mask.balance && !mask.nonce && !mask.code
8922}
8923
8924fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
8925    let mut failed = Vec::new();
8926    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
8927    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
8928
8929    for request in requests {
8930        for target in &request.targets {
8931            match target {
8932                ResyncTarget::StorageSlot { address, slot } => {
8933                    push_storage_resync_slot(
8934                        &mut storage_groups,
8935                        &request.id,
8936                        &request.block,
8937                        *address,
8938                        *slot,
8939                    );
8940                }
8941                ResyncTarget::StorageSlots { address, slots } => {
8942                    for slot in slots {
8943                        push_storage_resync_slot(
8944                            &mut storage_groups,
8945                            &request.id,
8946                            &request.block,
8947                            *address,
8948                            *slot,
8949                        );
8950                    }
8951                }
8952                ResyncTarget::Account { address, fields } => {
8953                    account_targets.push(AccountResyncTarget {
8954                        request_id: request.id.clone(),
8955                        block: request.block.clone(),
8956                        address: *address,
8957                        fields: *fields,
8958                    });
8959                }
8960            }
8961        }
8962    }
8963
8964    let mut state_updates = Vec::new();
8965    resolve_trace_resyncs(
8966        cache,
8967        &mut storage_groups,
8968        &mut account_targets,
8969        &mut state_updates,
8970    );
8971
8972    if !storage_groups.is_empty() {
8973        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
8974            for group in storage_groups {
8975                let block = group.block.clone();
8976                let fetches: Vec<(Address, U256)> = group
8977                    .slots
8978                    .iter()
8979                    .map(|slot| (slot.address, slot.slot))
8980                    .collect();
8981                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
8982                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
8983                    .slots
8984                    .iter()
8985                    .cloned()
8986                    .map(|slot| ((slot.address, slot.slot), slot))
8987                    .collect();
8988
8989                for (address, slot, fetched) in results {
8990                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
8991                        continue;
8992                    };
8993                    match fetched {
8994                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
8995                        Err(error) => {
8996                            let message = error.to_string();
8997                            push_resync_failures(
8998                                &mut failed,
8999                                &block,
9000                                requested_slot.origins,
9001                                ResyncFailureKind::StorageFetchFailed,
9002                                message,
9003                            );
9004                        }
9005                    }
9006                }
9007
9008                for requested_slot in group.slots {
9009                    if pending
9010                        .remove(&(requested_slot.address, requested_slot.slot))
9011                        .is_some()
9012                    {
9013                        push_resync_failures(
9014                            &mut failed,
9015                            &block,
9016                            requested_slot.origins,
9017                            ResyncFailureKind::StorageFetchOmitted,
9018                            "storage batch fetcher did not return a value for slot".to_string(),
9019                        );
9020                    }
9021                }
9022            }
9023        } else {
9024            for group in storage_groups {
9025                let block = group.block.clone();
9026                for slot in group.slots {
9027                    push_resync_failures(
9028                        &mut failed,
9029                        &block,
9030                        slot.origins,
9031                        ResyncFailureKind::MissingStorageFetcher,
9032                        "storage resync requires a storage batch fetcher".to_string(),
9033                    );
9034                }
9035            }
9036        }
9037    }
9038
9039    if !account_targets.is_empty() {
9040        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
9041            // ONE seam invocation per distinct resync block (targets may pin
9042            // different blocks): eth_getProof is single-address at the RPC
9043            // level, so batching the addresses lets the fetcher fan the
9044            // requests out concurrently instead of paying one round trip per
9045            // account. Root-only probes: account fields need no storage keys.
9046            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
9047            for account in account_targets {
9048                let block_id = resync_block_to_block_id(&account.block);
9049                match groups
9050                    .iter_mut()
9051                    .find(|(group_block, _)| *group_block == block_id)
9052                {
9053                    Some((_, group)) => group.push(account),
9054                    None => groups.push((block_id, vec![account])),
9055                }
9056            }
9057            for (block_id, group) in groups {
9058                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
9059                    group
9060                        .iter()
9061                        .map(|account| (account.address, vec![]))
9062                        .collect(),
9063                    block_id,
9064                )
9065                .into_iter()
9066                .collect();
9067                for account in group {
9068                    // `get` + clone rather than `remove`: two targets for the
9069                    // same address in one group must both resolve from the
9070                    // single probe.
9071                    match probes.get(&account.address).cloned() {
9072                        Some(Ok(proof)) => {
9073                            // Build an authoritative account update from the requested
9074                            // field mask. Use the MATERIALIZING `account_upsert` so a
9075                            // resync applies even to a cold account (a partial `Account`
9076                            // patch on a cold address is silently skipped).
9077                            let mut patch = AccountPatch::default();
9078                            if account.fields.balance {
9079                                patch = patch.balance(proof.balance);
9080                            }
9081                            if account.fields.nonce {
9082                                patch = patch.nonce(proof.nonce);
9083                            }
9084                            // Note: `AccountProof` carries `code_hash`, not code bytes;
9085                            // the `eth_getProof` seam cannot supply runtime code, so a
9086                            // code-field resync is a no-op here (code freshness is
9087                            // handled by a later wave). We still materialize the account
9088                            // so requested balance/nonce fields take effect.
9089                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9090                        }
9091                        Some(Err(error)) => {
9092                            failed.push(ResyncFailure {
9093                                request_id: account.request_id,
9094                                block: account.block,
9095                                target: ResyncTarget::Account {
9096                                    address: account.address,
9097                                    fields: account.fields,
9098                                },
9099                                kind: ResyncFailureKind::AccountFetchFailed,
9100                                message: error.to_string(),
9101                            });
9102                        }
9103                        None => {
9104                            failed.push(ResyncFailure {
9105                                request_id: account.request_id,
9106                                block: account.block,
9107                                target: ResyncTarget::Account {
9108                                    address: account.address,
9109                                    fields: account.fields,
9110                                },
9111                                kind: ResyncFailureKind::AccountFetchOmitted,
9112                                message:
9113                                    "account proof fetcher did not return a result for address"
9114                                        .to_string(),
9115                            });
9116                        }
9117                    }
9118                }
9119            }
9120        } else {
9121            for account in account_targets {
9122                failed.push(ResyncFailure {
9123                    request_id: account.request_id,
9124                    block: account.block,
9125                    target: ResyncTarget::Account {
9126                        address: account.address,
9127                        fields: account.fields,
9128                    },
9129                    kind: ResyncFailureKind::MissingAccountFetcher,
9130                    message: "account resync requires an account proof fetcher".to_string(),
9131                });
9132            }
9133        }
9134    }
9135
9136    let diff = if state_updates.is_empty() {
9137        StateDiff::default()
9138    } else {
9139        cache.apply_updates(&state_updates)
9140    };
9141
9142    ResyncReport {
9143        requested: requests.to_vec(),
9144        state_updates,
9145        diff,
9146        failed,
9147    }
9148}
9149
9150fn push_resync_failures(
9151    failed: &mut Vec<ResyncFailure>,
9152    block: &ResyncBlock,
9153    origins: Vec<StorageFetchOrigin>,
9154    kind: ResyncFailureKind,
9155    message: String,
9156) {
9157    for origin in origins {
9158        failed.push(ResyncFailure {
9159            request_id: origin.request_id,
9160            block: block.clone(),
9161            target: origin.target,
9162            kind,
9163            message: message.clone(),
9164        });
9165    }
9166}
9167
9168fn push_storage_resync_slot(
9169    groups: &mut Vec<StorageFetchGroup>,
9170    request_id: &ResyncId,
9171    block: &ResyncBlock,
9172    address: Address,
9173    slot: U256,
9174) {
9175    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
9176        index
9177    } else {
9178        groups.push(StorageFetchGroup {
9179            block: block.clone(),
9180            slots: Vec::new(),
9181            seen: HashSet::new(),
9182        });
9183        groups.len() - 1
9184    };
9185
9186    let group = &mut groups[group_index];
9187    let origin = StorageFetchOrigin {
9188        request_id: request_id.clone(),
9189        target: ResyncTarget::StorageSlot { address, slot },
9190    };
9191    if group.seen.insert((address, slot)) {
9192        group.slots.push(StorageFetchSlot {
9193            address,
9194            slot,
9195            origins: vec![origin],
9196        });
9197    } else if let Some(existing) = group
9198        .slots
9199        .iter_mut()
9200        .find(|existing| existing.address == address && existing.slot == slot)
9201    {
9202        existing.origins.push(origin);
9203    }
9204}
9205
9206fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
9207    match block {
9208        ResyncBlock::Latest => BlockId::latest(),
9209        ResyncBlock::Pending => BlockId::pending(),
9210        ResyncBlock::Safe => BlockId::safe(),
9211        ResyncBlock::Finalized => BlockId::finalized(),
9212        ResyncBlock::Number(number) => BlockId::number(*number),
9213        ResyncBlock::Hash {
9214            number: _,
9215            hash,
9216            require_canonical,
9217        } => BlockId::from((*hash, Some(*require_canonical))),
9218    }
9219}
9220
9221impl<N: Network> RegisteredHandler<N> {
9222    fn matches(&self, input: &ReactiveInput<N>) -> bool {
9223        self.interests
9224            .iter()
9225            .any(|interest| interest_matches(interest, input))
9226    }
9227
9228    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
9229        self.interests.iter().find_map(|interest| match interest {
9230            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
9231                handler_id: self.id.clone(),
9232                route_key: interest.route_key(log),
9233            }),
9234            ReactiveInterest::Logs(_)
9235            | ReactiveInterest::Blocks(_)
9236            | ReactiveInterest::PendingTransactions(_) => None,
9237        })
9238    }
9239}
9240
9241fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
9242    let mut candidate = next.clone();
9243    let mut insertion_index = filters.len();
9244    let mut index = 0;
9245    while index < filters.len() {
9246        if filters[index].block_option != candidate.block_option {
9247            index += 1;
9248            continue;
9249        }
9250        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
9251            candidate = merged;
9252            insertion_index = insertion_index.min(index);
9253            filters.remove(index);
9254            index = 0;
9255        } else {
9256            index += 1;
9257        }
9258    }
9259    filters.insert(insertion_index.min(filters.len()), candidate);
9260}
9261
9262fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
9263    if filter_subsumes(left, right) {
9264        return Some(left.clone());
9265    }
9266    if filter_subsumes(right, left) {
9267        return Some(right.clone());
9268    }
9269    let differing_dimensions = usize::from(left.address != right.address)
9270        + left
9271            .topics
9272            .iter()
9273            .zip(right.topics.iter())
9274            .filter(|(left, right)| left != right)
9275            .count();
9276    if differing_dimensions != 1 {
9277        return None;
9278    }
9279
9280    let mut merged = left.clone();
9281    if merged.address != right.address {
9282        merge_filter_set(&mut merged.address, &right.address);
9283    } else {
9284        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
9285            if merged_topic != right_topic {
9286                merge_filter_set(merged_topic, right_topic);
9287                break;
9288            }
9289        }
9290    }
9291    Some(merged)
9292}
9293
9294fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
9295    filter_set_subsumes(&left.address, &right.address)
9296        && left
9297            .topics
9298            .iter()
9299            .zip(right.topics.iter())
9300            .all(|(left, right)| filter_set_subsumes(left, right))
9301}
9302
9303fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
9304    left.is_empty()
9305        || (!right.is_empty()
9306            && right
9307                .iter()
9308                .all(|value| left.iter().any(|known| known == value)))
9309}
9310
9311fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
9312    if target.is_empty() {
9313        return;
9314    }
9315    if source.is_empty() {
9316        *target = FilterSet::default();
9317        return;
9318    }
9319    for value in source.iter() {
9320        target.insert(value.clone());
9321    }
9322}
9323
9324#[derive(Clone, Debug)]
9325struct HandlerExecution {
9326    handler_id: HandlerId,
9327    quality: StateEffectQuality,
9328    tags: Vec<ReportTag>,
9329    state_updates: Vec<StateUpdate>,
9330    invalidations: Vec<InvalidationRequest>,
9331    resyncs: Vec<ResyncRequest>,
9332    speculative: Vec<SpeculativeRequest>,
9333    hook_signals: Vec<HookSignal>,
9334}
9335
9336impl HandlerExecution {
9337    fn from_outcome(
9338        handler_id: HandlerId,
9339        input_ref: InputRef,
9340        outcome: HandlerOutcome,
9341        preconfirmed: bool,
9342    ) -> Self {
9343        let mut state_updates = Vec::new();
9344        let mut invalidations = Vec::new();
9345        let mut resyncs = Vec::new();
9346        let mut speculative = Vec::new();
9347        let mut hook_signals = Vec::new();
9348
9349        for effect in outcome.effects {
9350            match effect {
9351                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
9352                ReactiveEffect::Invalidate(invalidation) => {
9353                    state_updates.push(StateUpdate::purge(
9354                        invalidation.address,
9355                        invalidation.scope.clone(),
9356                    ));
9357                    invalidations.push(invalidation);
9358                }
9359                ReactiveEffect::Resync(mut request) => {
9360                    if preconfirmed {
9361                        request.block = ResyncBlock::Pending;
9362                    }
9363                    resyncs.push(request);
9364                }
9365                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
9366                ReactiveEffect::Speculative(mut request) => {
9367                    request.input_ref = input_ref;
9368                    speculative.push(request);
9369                }
9370            }
9371        }
9372
9373        Self {
9374            handler_id,
9375            quality: outcome.quality,
9376            tags: outcome.tags,
9377            state_updates,
9378            invalidations,
9379            resyncs,
9380            speculative,
9381            hook_signals,
9382        }
9383    }
9384}
9385
9386fn dedupe_records<N: Network>(
9387    records: Vec<ReactiveInputRecord<N>>,
9388) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
9389    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
9390    let mut deduped = Vec::with_capacity(records.len());
9391    for record in records {
9392        let identity = record.validated_identity()?;
9393        if !record.is_payload_deduplicable() {
9394            deduped.push(record);
9395            continue;
9396        }
9397        if let Some(index) = positions.get(&identity).copied() {
9398            let merged = deduped[index].merge_compatible_duplicate(&record)?;
9399            debug_assert!(merged, "same indexed identity is deduplicable");
9400        } else {
9401            positions.insert(identity, deduped.len());
9402            deduped.push(record);
9403        }
9404    }
9405    Ok(deduped)
9406}
9407
9408fn dedupe_scoped_records<N: Network>(
9409    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9410) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
9411    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
9412    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
9413        Vec::with_capacity(records.len());
9414    for (record, audience, delivery_scope) in records {
9415        let identity = record.validated_identity()?;
9416        if !record.is_payload_deduplicable() {
9417            deduped.push((record, audience, delivery_scope));
9418            continue;
9419        }
9420        if let Some(index) = positions.get(&identity).copied() {
9421            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
9422            debug_assert!(merged, "same indexed identity is deduplicable");
9423            merge_delivery_audience(&mut deduped[index].1, audience);
9424            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
9425        } else {
9426            positions.insert(identity, deduped.len());
9427            deduped.push((record, audience, delivery_scope));
9428        }
9429    }
9430    Ok(deduped)
9431}
9432
9433fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
9434    *into = match (*into, incoming) {
9435        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
9436        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
9437            DeliveryScope::CanonicalProgress
9438        }
9439        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
9440        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
9441        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
9442        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
9443    };
9444}
9445
9446fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
9447    match (&mut *into, incoming) {
9448        (DeliveryAudience::All, _) => {}
9449        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
9450        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
9451            for owner in incoming {
9452                if !current.contains(&owner) {
9453                    current.push(owner);
9454                }
9455            }
9456        }
9457        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9458            current.retain(|owner| incoming.contains(owner));
9459        }
9460        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9461            excluded.retain(|owner| !included.contains(owner));
9462        }
9463        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9464            let DeliveryAudience::Owners(included) = current else {
9465                unreachable!("match arm restricts the audience variant")
9466            };
9467            excluded.retain(|owner| !included.contains(owner));
9468            *current = DeliveryAudience::AllExcept(excluded);
9469        }
9470    }
9471}
9472
9473fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9474    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9475        records.into_iter().enumerate().collect();
9476    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9477    indexed.into_iter().map(|(_, record)| record).collect()
9478}
9479
9480fn sort_scoped_records<N: Network>(
9481    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9482) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9483    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9484    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9485    indexed
9486        .into_iter()
9487        .map(|(_, scoped_record)| scoped_record)
9488        .collect()
9489}
9490
9491fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9492    if let Some((block, _)) = reorg_signal_block(record) {
9493        return RecordSortKey {
9494            class: 0,
9495            block_number: block.number,
9496            record_class: 0,
9497            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9498            log_index: record.context.log_index.unwrap_or(u64::MAX),
9499            original_index: index,
9500        };
9501    }
9502    if is_canonical_status(&record.context.chain_status)
9503        && let Some(block) = record.context.block.as_ref()
9504    {
9505        let (record_class, transaction_index, log_index) = match &record.input {
9506            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9507            ReactiveInput::Log(log) if !log.removed => (
9508                1,
9509                log.transaction_index
9510                    .or(record.context.transaction_index)
9511                    .unwrap_or(u64::MAX),
9512                log.log_index
9513                    .or(record.context.log_index)
9514                    .unwrap_or(u64::MAX),
9515            ),
9516            ReactiveInput::Log(_)
9517            | ReactiveInput::PendingTxHash(_)
9518            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9519        };
9520        return RecordSortKey {
9521            class: 1,
9522            block_number: block.number,
9523            record_class,
9524            transaction_index,
9525            log_index,
9526            original_index: index,
9527        };
9528    }
9529
9530    RecordSortKey {
9531        class: 2,
9532        block_number: 0,
9533        record_class: 0,
9534        transaction_index: 0,
9535        log_index: 0,
9536        original_index: index,
9537    }
9538}
9539
9540#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9541struct RecordSortKey {
9542    class: u8,
9543    block_number: u64,
9544    record_class: u8,
9545    transaction_index: u64,
9546    log_index: u64,
9547    original_index: usize,
9548}
9549
9550fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9551    match (interest, input) {
9552        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9553        (
9554            ReactiveInterest::Blocks(BlockInterest {
9555                mode: BlockInterestMode::Header,
9556            }),
9557            ReactiveInput::BlockHeader(_),
9558        ) => true,
9559        (
9560            ReactiveInterest::Blocks(BlockInterest {
9561                mode: BlockInterestMode::FullBlock,
9562            }),
9563            ReactiveInput::FullBlock(_),
9564        ) => true,
9565        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9566            interest.matches_hash_only()
9567        }
9568        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9569            interest.matches_tx(tx)
9570        }
9571        _ => false,
9572    }
9573}
9574
9575fn validate_effects(
9576    input_ref: InputRef,
9577    ctx: &ReactiveContext,
9578    handler_id: &HandlerId,
9579    effects: &[ReactiveEffect],
9580) -> Result<(), ReactiveError> {
9581    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9582        || matches!(input_ref, InputRef::PendingTx { .. });
9583    if !pending {
9584        return Ok(());
9585    }
9586
9587    for effect in effects {
9588        let effect_kind = match effect {
9589            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9590            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9591            ReactiveEffect::Resync(_) => Some("resync"),
9592            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9593        };
9594        if let Some(effect_kind) = effect_kind {
9595            return Err(ReactiveError::InvalidPendingEffect {
9596                input_ref: Box::new(input_ref),
9597                handler_id: handler_id.clone(),
9598                effect_kind,
9599            });
9600        }
9601    }
9602    Ok(())
9603}
9604
9605fn detect_conflicts(
9606    input_ref: InputRef,
9607    executions: &[HandlerExecution],
9608) -> Result<(), ReactiveError> {
9609    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9610    for execution in executions {
9611        for update in &execution.state_updates {
9612            for (target, value) in absolute_writes(update) {
9613                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9614                    if previous_value != &value {
9615                        return Err(ReactiveError::ConflictingEffects {
9616                            input_ref: Box::new(input_ref),
9617                            target: Box::new(target),
9618                            first: previous_handler.clone(),
9619                            second: execution.handler_id.clone(),
9620                        });
9621                    }
9622                } else {
9623                    writes.insert(target, (value, execution.handler_id.clone()));
9624                }
9625            }
9626        }
9627    }
9628    Ok(())
9629}
9630
9631fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9632    match update {
9633        StateUpdate::Slot {
9634            address,
9635            slot,
9636            value,
9637        } => vec![(
9638            EffectTarget::StorageSlot {
9639                address: *address,
9640                slot: *slot,
9641            },
9642            AbsoluteValue::U256(*value),
9643        )],
9644        StateUpdate::SlotMasked {
9645            address,
9646            slot,
9647            mask,
9648            value,
9649        } => vec![(
9650            EffectTarget::MaskedStorageSlot {
9651                address: *address,
9652                slot: *slot,
9653                mask: *mask,
9654            },
9655            AbsoluteValue::U256(*value),
9656        )],
9657        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9658            account_patch_writes(*address, patch)
9659        }
9660        StateUpdate::SlotDelta { .. }
9661        | StateUpdate::BalanceDelta { .. }
9662        | StateUpdate::Purge { .. } => Vec::new(),
9663    }
9664}
9665
9666fn account_patch_writes(
9667    address: Address,
9668    patch: &AccountPatch,
9669) -> Vec<(EffectTarget, AbsoluteValue)> {
9670    let mut writes = Vec::new();
9671    if let Some(balance) = patch.balance {
9672        writes.push((
9673            EffectTarget::AccountBalance { address },
9674            AbsoluteValue::U256(balance),
9675        ));
9676    }
9677    if let Some(nonce) = patch.nonce {
9678        writes.push((
9679            EffectTarget::AccountNonce { address },
9680            AbsoluteValue::U64(nonce),
9681        ));
9682    }
9683    if let Some(code) = &patch.code {
9684        writes.push((
9685            EffectTarget::AccountCode { address },
9686            AbsoluteValue::Bytes(code.clone()),
9687        ));
9688    }
9689    writes
9690}
9691
9692fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9693    match input {
9694        ReactiveInput::Log(log) => InputRef::Log {
9695            chain_id: ctx.chain_id,
9696            block_hash: log
9697                .block_hash
9698                .or(ctx.block.as_ref().map(|block| block.hash))
9699                .unwrap_or_default(),
9700            transaction_hash: log.transaction_hash.unwrap_or_default(),
9701            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9702        },
9703        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9704            chain_id: ctx.chain_id,
9705            hash: *hash,
9706        },
9707        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9708            chain_id: ctx.chain_id,
9709            hash: tx.tx_hash(),
9710        },
9711        ReactiveInput::BlockHeader(header) => InputRef::Block {
9712            chain_id: ctx.chain_id,
9713            hash: header.hash(),
9714            number: header.number(),
9715        },
9716        ReactiveInput::FullBlock(block) => {
9717            let header = block.header();
9718            InputRef::Block {
9719                chain_id: ctx.chain_id,
9720                hash: header.hash(),
9721                number: header.number(),
9722            }
9723        }
9724    }
9725}
9726
9727fn is_canonical_status(status: &ChainStatus) -> bool {
9728    matches!(
9729        status,
9730        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9731    )
9732}
9733
9734/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9735pub struct EventDecoderHandler {
9736    id: HandlerId,
9737    decoder: Arc<dyn EventDecoder>,
9738    interest: LogInterest,
9739}
9740
9741impl EventDecoderHandler {
9742    /// Create an adapter from a decoder and log interest.
9743    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9744        Self {
9745            id,
9746            decoder,
9747            interest,
9748        }
9749    }
9750}
9751
9752impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9753    fn id(&self) -> HandlerId {
9754        self.id.clone()
9755    }
9756
9757    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9758        vec![ReactiveInterest::Logs(self.interest.clone())]
9759    }
9760
9761    fn handle(
9762        &self,
9763        _ctx: &ReactiveContext,
9764        input: &ReactiveInput<N>,
9765        state: &dyn StateView,
9766    ) -> Result<HandlerOutcome, HandlerError> {
9767        let ReactiveInput::Log(log) = input else {
9768            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9769        };
9770
9771        Ok(HandlerOutcome {
9772            effects: self
9773                .decoder
9774                .decode(&log.inner, state)
9775                .into_iter()
9776                .map(ReactiveEffect::StateUpdate)
9777                .collect(),
9778            quality: StateEffectQuality::ExactFromInput,
9779            tags: Vec::new(),
9780        })
9781    }
9782}
9783
9784/// One independently negotiable event-subscriber behavior.
9785#[derive(
9786    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9787)]
9788#[non_exhaustive]
9789pub enum SubscriberCapability {
9790    /// Emit EVM logs.
9791    Logs,
9792    /// Emit block headers.
9793    BlockHeaders,
9794    /// Emit full blocks with transaction bodies.
9795    FullBlocks,
9796    /// Emit pending transaction hashes.
9797    PendingTransactionHashes,
9798    /// Emit hydrated pending transactions.
9799    PendingTransactions,
9800    /// Fetch historical data from a caller-selected anchor.
9801    HistoricalBackfill,
9802    /// Follow live chain data.
9803    Live,
9804    /// Recover the complete committed consumer position after reconnect or
9805    /// restart, including any unacknowledged delivery.
9806    ///
9807    /// An implementation may satisfy this with native stream replay or with a
9808    /// durable cursor plus deterministic historical reconciliation of an
9809    /// ephemeral live child. The end-to-end subscriber must still prove there
9810    /// is no gap between the restored position and resumed live delivery. If an
9811    /// old delivery token is emitted again, that token must identify the same
9812    /// immutable delivery and pass the engine's witness check.
9813    DurableReplay,
9814    /// Preserve logical handler ownership on delivered batches.
9815    OwnerScopedDelivery,
9816    /// Add and remove interests without replacing the complete session.
9817    DynamicInterests,
9818    /// Emit explicit canonical branch transitions.
9819    ExplicitReorgs,
9820    /// Emit safe and finalized head updates.
9821    FinalityUpdates,
9822    /// Emit ordered synchronization or source-cutover barriers.
9823    Barriers,
9824    /// Emit sequencer pre-confirmations into a disposable state overlay.
9825    Preconfirmations,
9826}
9827
9828/// Capability set advertised by an [`EventSubscriber`].
9829///
9830/// The default is deliberately empty: callers can safely reject a topology
9831/// when an older or minimal implementation has not opted into a required
9832/// behavior.
9833#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9834pub struct SubscriberCapabilities {
9835    supported: BTreeSet<SubscriberCapability>,
9836}
9837
9838impl SubscriberCapabilities {
9839    /// Construct a capability set from supported behaviors.
9840    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9841        Self {
9842            supported: capabilities.into_iter().collect(),
9843        }
9844    }
9845
9846    /// Test one independently negotiable behavior.
9847    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9848        self.supported.contains(&capability)
9849    }
9850
9851    /// Iterate supported behaviors in stable order.
9852    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
9853        self.supported.iter().copied()
9854    }
9855
9856    /// Whether the subscriber follows live chain data.
9857    pub fn supports_live(&self) -> bool {
9858        self.supports(SubscriberCapability::Live)
9859    }
9860
9861    /// Whether the subscriber can durably recover its committed position and
9862    /// any unacknowledged delivery without an event gap.
9863    pub fn supports_durable_replay(&self) -> bool {
9864        self.supports(SubscriberCapability::DurableReplay)
9865    }
9866
9867    /// Whether the subscriber emits explicit branch transitions.
9868    pub fn supports_explicit_reorgs(&self) -> bool {
9869        self.supports(SubscriberCapability::ExplicitReorgs)
9870    }
9871}
9872
9873impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
9874    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
9875        Self::new(iter)
9876    }
9877}
9878
9879/// Provider-agnostic subscriber interface.
9880pub trait EventSubscriber<N: Network = Ethereum>: Send {
9881    /// Chain identity attached to emitted records, when it has been resolved.
9882    ///
9883    /// Remote and provider-backed subscribers should cache one authoritative
9884    /// identity before exposing input. Returning `None` is reserved for
9885    /// synthetic or genuinely chain-agnostic subscribers; composite sources
9886    /// can use this hook to reject accidentally mixed networks.
9887    fn chain_id(&self) -> Option<u64> {
9888        None
9889    }
9890
9891    /// Behaviors this subscriber can uphold for topology validation.
9892    fn capabilities(&self) -> SubscriberCapabilities {
9893        SubscriberCapabilities::default()
9894    }
9895
9896    /// Replace all interests registered with the subscriber.
9897    ///
9898    /// Implementations may use this as a full setup/reset operation. The
9899    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
9900    /// delivery/dedupe bookkeeping when this method is called.
9901    ///
9902    /// The returned operation must complete only after the replacement has
9903    /// committed to the subscriber's desired state. Remote implementations can
9904    /// use this asynchronous boundary to wait for an authoritative service-side
9905    /// acknowledgement before returning `Ok(())`. On error, or when the future
9906    /// is dropped before completion, the previously committed desired state
9907    /// must remain authoritative (or be reconciled before later delivery can
9908    /// expose the uncommitted change) so callers can safely retry.
9909    ///
9910    /// # Errors
9911    ///
9912    /// The returned operation reports [`SubscriberError`] when the replacement
9913    /// cannot be validated or committed by the underlying source.
9914    fn register_interests(
9915        &mut self,
9916        interests: &[ReactiveInterest<N>],
9917    ) -> SubscriberOperation<'_, ()>;
9918
9919    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
9920    ///
9921    /// The returned future must be cancellation-safe: dropping it while pending
9922    /// must not discard a complete input that a later call could otherwise
9923    /// deliver. Composite subscribers use this property to race historical and
9924    /// live sources without dedicating a task to each transport.
9925    ///
9926    /// # Errors
9927    ///
9928    /// The returned future reports [`SubscriberError`] for transport,
9929    /// continuity, decoding, or source-resource failures.
9930    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
9931
9932    /// Restore the subscriber's committed position before polling resumes.
9933    ///
9934    /// The engine invokes this synchronously from
9935    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
9936    /// recovery state and before publishing that state as resumed. Implementations
9937    /// should validate that provider/service cursors cannot regress and seed any
9938    /// source epoch or overlap history required for safe replay. A composite may
9939    /// rebuild an ephemeral live child from `coverage_head` plus historical
9940    /// reconciliation rather than require that child to replay bytes itself, but
9941    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
9942    /// complete restore closes that cutover gap before exposing live input. On
9943    /// error, either
9944    /// the prior position must remain authoritative, or the subscriber may retain
9945    /// this *exact* restore as pending intent; in the latter case it must block
9946    /// delivery and reject conflicting restores until retry/reconciliation commits
9947    /// the same position. This permits synchronous adapters over durable remote
9948    /// state without exposing a half-restored stream.
9949    ///
9950    /// # Errors
9951    ///
9952    /// Returns [`SubscriberError`] when the position is invalid, regresses or
9953    /// conflicts with committed source state, or cannot be restored durably.
9954    fn restore_position(
9955        &mut self,
9956        _position: &SubscriberResumePosition,
9957    ) -> Result<(), SubscriberError> {
9958        Ok(())
9959    }
9960
9961    /// Commit a subscriber-owned delivery token after runtime ingestion.
9962    ///
9963    /// Ephemeral subscribers can rely on this no-op default. Durable remote
9964    /// subscribers should make acknowledgement idempotent because cancellation
9965    /// or transport failure can cause a successfully ingested batch to replay.
9966    /// Re-emitting a token must reproduce the same immutable records, routing,
9967    /// chain controls, chain identity, and provider checkpoint; the checkpointed
9968    /// engine verifies its persisted delivery witness before skipping ingestion.
9969    ///
9970    /// # Errors
9971    ///
9972    /// The returned operation reports [`SubscriberError`] when the delivery
9973    /// token cannot be committed idempotently by the source.
9974    fn acknowledge_delivery(
9975        &mut self,
9976        _token: SubscriberDeliveryToken,
9977    ) -> SubscriberOperation<'_, ()> {
9978        Box::pin(async { Ok(()) })
9979    }
9980}
9981
9982/// Boxed, sendable future returned by subscriber lifecycle operations.
9983///
9984/// The output is generic so the same type can represent registration, removal,
9985/// and future acknowledgement values without requiring an async-trait helper.
9986pub type SubscriberOperation<'a, T> =
9987    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
9988
9989/// Boxed future returned by [`EventSubscriber::next_batch`].
9990pub type SubscriberNextBatch<'a, N> = Pin<
9991    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
9992>;
9993
9994/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
9995pub type SubscriberNextScopedBatch<'a, N> = Pin<
9996    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
9997>;
9998
9999/// Subscriber mode requested for the Alloy subscriber.
10000#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
10001pub enum SubscriberMode {
10002    /// Prefer the default compiled transport.
10003    ///
10004    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
10005    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
10006    /// the opt-in `reactive-polling` feature is enabled.
10007    #[default]
10008    Auto,
10009    /// Use provider pubsub streams.
10010    PubSub,
10011    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
10012    Polling,
10013}
10014
10015#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10016enum FlashblocksAdapter {
10017    NativeSubscriptions,
10018    PendingStatePolling,
10019}
10020
10021fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
10022    match chain_id {
10023        8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
10024        10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
10025        _ => None,
10026    }
10027}
10028
10029/// Subscriber configuration.
10030#[derive(Clone, Debug, PartialEq, Eq)]
10031pub struct SubscriberConfig {
10032    /// Flashblocks delivery policy. Provider support itself is configured by
10033    /// the transport's single `flashblocks` endpoint flag.
10034    pub preconfirmations: PreconfirmationMode,
10035    /// Cadence for certifying sealed canonical heads while connected to a
10036    /// Flashblocks endpoint whose `newHeads` stream may contain partial heads.
10037    pub canonical_head_poll_interval: Duration,
10038    /// Maximum time allowed for one provider request that certifies a
10039    /// canonical head while Flashblocks are active.
10040    pub canonical_head_request_timeout: Duration,
10041    /// Optimism pending-state sampling cadence.
10042    ///
10043    /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers
10044    /// currently expose the interoperable Flashblocks surface through
10045    /// `pending` RPC reads, so one generation-pinned sampler reads the
10046    /// cumulative pending block, its exact hash-addressed parent, filtered
10047    /// pending-block logs, and bounded exact transaction receipts.
10048    pub flashblock_poll_interval: Duration,
10049    /// Consecutive pending-state request failure allowance.
10050    ///
10051    /// A successful sampling tick resets this counter. Semantic integrity
10052    /// failures, such as non-monotonic transaction membership or malformed
10053    /// logs, are never retried through this allowance.
10054    pub max_consecutive_flashblock_poll_failures: usize,
10055    /// Maximum pending receipts per sampling tick.
10056    ///
10057    /// Receipts are requested by exact transaction hash in one JSON-RPC batch,
10058    /// because separate `eth_getBlockReceipts("pending")` responses can refer
10059    /// to a different cumulative Flashblock. The rolling total-method budget
10060    /// may impose a lower effective per-tick limit; with the defaults and one
10061    /// log filter, at most seven receipts are requested per tick.
10062    pub max_pending_transaction_receipts_per_tick: usize,
10063    /// Pending-state RPC method budget per rolling one-second window.
10064    ///
10065    /// The sampler reserves capacity for the pending-block, exact-parent, and
10066    /// filtered-log methods implied by its cadence and filter plan, plus the
10067    /// exact-parent canonical-head poll when block interests require it. Exact
10068    /// receipt hydration uses only an evenly apportioned remainder. Request
10069    /// timestamps enforce the ceiling across actual ticks, including delayed
10070    /// ticks. The default leaves headroom below common paid-provider limits of
10071    /// 50 requests per second.
10072    pub max_flashblock_rpc_requests_per_second: usize,
10073    /// Hydrate pending transaction hashes into full bodies when possible.
10074    pub hydrate_pending_transactions: bool,
10075    /// Verify each canonical log's block identity through RPC and enrich its
10076    /// context with the exact parent hash before delivery.
10077    ///
10078    /// Enable this when a strict coordinator (such as a hybrid historical/live
10079    /// source) must prove canonical ancestry from log-only pubsub events.
10080    /// Verification is cached per block, so the provider is queried at most
10081    /// once for each distinct canonical block retained in the dedupe window.
10082    /// For high-volume pubsub filters, configure
10083    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
10084    /// provider so verification responses cannot be starved by notifications.
10085    pub verify_log_block_context: bool,
10086    /// Maximum records to emit per batch.
10087    pub max_batch_size: usize,
10088    /// Maximum distinct contract addresses placed in one provider-side log
10089    /// subscription. Compatible logical owner filters are fanned into address
10090    /// supersets up to this limit; exact owner routing still happens locally.
10091    pub max_log_addresses_per_subscription: usize,
10092    /// Maximum records retained across the delivery queue and hidden
10093    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
10094    /// closed until a full interest reset, because dropping an event would
10095    /// create an unknowable continuity gap.
10096    pub max_pending_records: usize,
10097    /// Maximum lazy owner-backfill requests retained at once.
10098    pub max_pending_backfills: usize,
10099    /// Maximum approximate encoded bytes accepted from one historical log
10100    /// response (fixed log identity fields, topics, and data).
10101    pub max_backfill_log_bytes: usize,
10102    /// Maximum provider log requests concurrently in flight during bulk owner
10103    /// reconciliation.
10104    pub max_reconcile_requests_in_flight: usize,
10105    /// Reconnect policy for WebSocket/pubsub streams.
10106    pub reconnect: SubscriberReconnectConfig,
10107}
10108
10109impl Default for SubscriberConfig {
10110    fn default() -> Self {
10111        Self {
10112            preconfirmations: PreconfirmationMode::Disabled,
10113            canonical_head_poll_interval: Duration::from_millis(500),
10114            canonical_head_request_timeout: Duration::from_secs(3),
10115            flashblock_poll_interval: Duration::from_millis(250),
10116            max_consecutive_flashblock_poll_failures: 10,
10117            max_pending_transaction_receipts_per_tick: 32,
10118            max_flashblock_rpc_requests_per_second: 40,
10119            hydrate_pending_transactions: false,
10120            verify_log_block_context: false,
10121            max_batch_size: 1024,
10122            max_log_addresses_per_subscription: 1024,
10123            max_pending_records: 16_384,
10124            max_pending_backfills: 4_096,
10125            max_backfill_log_bytes: 64 * 1024 * 1024,
10126            max_reconcile_requests_in_flight: 8,
10127            reconnect: SubscriberReconnectConfig::default(),
10128        }
10129    }
10130}
10131
10132/// Provider surface established for one Flashblocks generation.
10133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10134pub enum FlashblocksDelivery {
10135    /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams.
10136    NativeSubscriptions,
10137    /// Generation-pinned `pending` block and log sampling.
10138    PendingStatePolling,
10139    /// Standardized updates supplied by an application-managed transport.
10140    #[cfg(feature = "raw-flashblocks-json")]
10141    ExternalUpdates,
10142}
10143
10144/// Request/response traffic issued by one Flashblocks subscriber generation.
10145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10146pub struct FlashblocksRpcMetrics {
10147    capability_requests: u64,
10148    provider_pair_chain_requests: u64,
10149    canonical_head_requests: u64,
10150    pending_block_requests: u64,
10151    pending_log_requests: u64,
10152    pending_receipt_requests: u64,
10153    pending_receipts_completed: u64,
10154    pending_receipts_unavailable: u64,
10155    failed_requests: u64,
10156    raced_samples: u64,
10157}
10158
10159impl FlashblocksRpcMetrics {
10160    /// Opportunistic `op_supportedCapabilities` probes attempted.
10161    pub const fn capability_requests(self) -> u64 {
10162        self.capability_requests
10163    }
10164
10165    /// Chain-identity requests used to verify an explicitly paired
10166    /// pending-state provider against the subscriber's stream provider.
10167    pub const fn provider_pair_chain_requests(self) -> u64 {
10168        self.provider_pair_chain_requests
10169    }
10170
10171    /// Exact parent-block requests used to fence pending and canonical state.
10172    pub const fn canonical_head_requests(self) -> u64 {
10173        self.canonical_head_requests
10174    }
10175
10176    /// Cumulative pending-block requests.
10177    pub const fn pending_block_requests(self) -> u64 {
10178        self.pending_block_requests
10179    }
10180
10181    /// Pending log-filter requests.
10182    pub const fn pending_log_requests(self) -> u64 {
10183        self.pending_log_requests
10184    }
10185
10186    /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash.
10187    /// Several methods may share one JSON-RPC batch transport request.
10188    pub const fn pending_receipt_requests(self) -> u64 {
10189        self.pending_receipt_requests
10190    }
10191
10192    /// Exact pending transaction receipts returned successfully.
10193    pub const fn pending_receipts_completed(self) -> u64 {
10194        self.pending_receipts_completed
10195    }
10196
10197    /// Exact pending transaction receipts that were not materialized yet and remain
10198    /// eligible for retry on the next cumulative sample.
10199    pub const fn pending_receipts_unavailable(self) -> u64 {
10200        self.pending_receipts_unavailable
10201    }
10202
10203    /// Provider request failures observed by a pending-state sampler.
10204    pub const fn failed_requests(self) -> u64 {
10205        self.failed_requests
10206    }
10207
10208    /// Samples discarded because the pending-log response advanced beyond
10209    /// the separately fetched cumulative block. The next tick retries from a
10210    /// fresh block/log pair; no partial speculative view is published.
10211    pub const fn raced_samples(self) -> u64 {
10212        self.raced_samples
10213    }
10214
10215    /// Total request/response calls attributable to Flashblocks qualification
10216    /// and sampling.
10217    pub const fn total_requests(self) -> u64 {
10218        self.capability_requests
10219            .saturating_add(self.provider_pair_chain_requests)
10220            .saturating_add(self.canonical_head_requests)
10221            .saturating_add(self.pending_block_requests)
10222            .saturating_add(self.pending_log_requests)
10223            .saturating_add(self.pending_receipt_requests)
10224    }
10225}
10226
10227/// Successful initial Flashblocks endpoint preflight.
10228///
10229/// This proves chain identity and either subscription acknowledgement for
10230/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, method
10231/// support for OP's bounded pending block/log sampler, or the canonical stream
10232/// topology paired with an application-managed standardized source.
10233/// Notification liveness and active-interest coverage remain acceptance-window
10234/// checks: a successful preflight alone must not qualify a source for live use.
10235#[derive(Clone, Debug, PartialEq, Eq)]
10236pub struct FlashblocksPreflight {
10237    chain_id: u64,
10238    provider: ProviderRef,
10239    delivery: FlashblocksDelivery,
10240    pending_log_subscriptions: usize,
10241    pending_log_filters: usize,
10242    advertised_capabilities: Option<serde_json::Value>,
10243}
10244
10245impl FlashblocksPreflight {
10246    /// Chain identity read from the pinned provider lease.
10247    pub const fn chain_id(&self) -> u64 {
10248        self.chain_id
10249    }
10250
10251    /// Provider generation selected for speculative updates.
10252    ///
10253    /// Built-in profiles preflight this provider's coupled request/subscription
10254    /// surfaces. External profiles retain caller-supplied provenance while the
10255    /// application qualifies the supplemental socket separately.
10256    pub const fn provider(&self) -> &ProviderRef {
10257        &self.provider
10258    }
10259
10260    /// Provider surface selected for this chain.
10261    pub const fn delivery(&self) -> FlashblocksDelivery {
10262        self.delivery
10263    }
10264
10265    /// Number of acknowledged pool-filtered `pendingLogs` subscriptions.
10266    ///
10267    /// This is zero for sampled and externally managed delivery profiles.
10268    pub const fn pending_log_subscriptions(&self) -> usize {
10269        self.pending_log_subscriptions
10270    }
10271
10272    /// Number of provider-facing log filters whose interests must be covered by
10273    /// the selected native, sampled, or external delivery surface.
10274    pub const fn pending_log_filters(&self) -> usize {
10275        self.pending_log_filters
10276    }
10277
10278    /// Opaque response from `op_supportedCapabilities`, when the provider
10279    /// implements that optional RPC method.
10280    pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
10281        self.advertised_capabilities.as_ref()
10282    }
10283}
10284
10285/// WebSocket/pubsub reconnect policy.
10286///
10287/// Reconnects are applied after an established subscription stream terminates.
10288/// Initial subscription failures are still returned immediately so deployment
10289/// mistakes, unsupported transports, and bad endpoints fail fast.
10290#[derive(Clone, Debug, PartialEq, Eq)]
10291pub struct SubscriberReconnectConfig {
10292    /// Whether pubsub streams should be recreated after termination.
10293    pub enabled: bool,
10294    /// Delay before the first reconnect attempt.
10295    pub initial_delay: Duration,
10296    /// Delay before the second reconnect attempt. Later retries double this
10297    /// delay up to [`Self::max_delay`].
10298    pub retry_delay: Duration,
10299    /// Maximum delay between reconnect attempts.
10300    pub max_delay: Duration,
10301    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
10302    pub max_attempts: Option<usize>,
10303    /// Number of recently emitted canonical input refs remembered to suppress
10304    /// duplicates across reconnect backfill and subscription replay.
10305    pub dedupe_window: usize,
10306}
10307
10308impl Default for SubscriberReconnectConfig {
10309    fn default() -> Self {
10310        Self {
10311            enabled: true,
10312            initial_delay: Duration::ZERO,
10313            retry_delay: Duration::from_millis(250),
10314            max_delay: Duration::from_secs(30),
10315            max_attempts: Some(3),
10316            dedupe_window: 4096,
10317        }
10318    }
10319}
10320
10321/// Historical log backfill requested when adding subscriber interests.
10322///
10323/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
10324/// pending-transaction interests are live-only. `AlloySubscriber` emits records
10325/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
10326/// owner registration adopts/subscribes the desired live filter first, then
10327/// reconciles history behind that live fence; startup/global replacement commits
10328/// topology and historical work as one desired-state transaction. A drained
10329/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
10330/// when the window held no logs), so the newly added filter gets the same
10331/// reconnect/catch-up protection an established one has.
10332#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10333pub struct SubscriberBackfill {
10334    from_block: u64,
10335    to_block: Option<u64>,
10336    retained_anchor: Option<BlockRef>,
10337}
10338
10339impl SubscriberBackfill {
10340    /// Backfill an inclusive block range.
10341    pub fn range(from_block: u64, to_block: u64) -> Self {
10342        Self {
10343            from_block,
10344            to_block: Some(to_block),
10345            retained_anchor: None,
10346        }
10347    }
10348
10349    /// Backfill from `from_block` through the provider's latest block.
10350    pub fn from_block(from_block: u64) -> Self {
10351        Self {
10352            from_block,
10353            to_block: None,
10354            retained_anchor: None,
10355        }
10356    }
10357
10358    /// Backfill inclusively from an exact retained canonical block.
10359    ///
10360    /// The Alloy subscriber verifies this number/hash against its provider
10361    /// before accepting any lazy catch-up response. Engine-managed mid-stream
10362    /// registration uses this form so owner replay cannot silently cross a
10363    /// reorged discovery boundary.
10364    pub fn from_canonical_block(block: BlockRef) -> Self {
10365        Self {
10366            from_block: block.number,
10367            to_block: None,
10368            retained_anchor: Some(block),
10369        }
10370    }
10371
10372    /// Backfill inclusively from an exact canonical block through an inclusive
10373    /// upper bound.
10374    ///
10375    /// # Errors
10376    ///
10377    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10378    /// retained anchor.
10379    pub fn from_canonical_block_through(
10380        block: BlockRef,
10381        to_block: u64,
10382    ) -> Result<Self, SubscriberError> {
10383        if to_block < block.number {
10384            return Err(SubscriberError::InvalidConfig(
10385                "inclusive backfill upper bound precedes its retained anchor",
10386            ));
10387        }
10388        Ok(Self {
10389            from_block: block.number,
10390            to_block: Some(to_block),
10391            retained_anchor: Some(block),
10392        })
10393    }
10394
10395    /// Backfill strictly after an exact canonical state baseline.
10396    ///
10397    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
10398    /// a restored cache already embodies every effect through `block`, so
10399    /// replaying that block would apply it twice. The retained block is still
10400    /// carried so the subscriber can prove that its provider is on the same
10401    /// canonical branch before accepting any post-baseline history.
10402    ///
10403    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
10404    /// exclusive range into an inclusive replay of the baseline block.
10405    ///
10406    /// # Errors
10407    ///
10408    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
10409    /// `u64::MAX` and therefore has no following block.
10410    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
10411        Self::after_canonical_block_inner(block, None)
10412    }
10413
10414    /// Backfill strictly after an exact canonical baseline through an
10415    /// inclusive upper bound.
10416    ///
10417    /// `to_block == block.number` represents a deliberately empty certified
10418    /// interval. Bounds before the retained baseline are rejected.
10419    ///
10420    /// # Errors
10421    ///
10422    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10423    /// baseline, or when a non-empty exclusive range would have to begin after
10424    /// block `u64::MAX`.
10425    pub fn after_canonical_block_through(
10426        block: BlockRef,
10427        to_block: u64,
10428    ) -> Result<Self, SubscriberError> {
10429        if to_block < block.number {
10430            return Err(SubscriberError::InvalidConfig(
10431                "exclusive backfill upper bound precedes its retained baseline",
10432            ));
10433        }
10434        Self::after_canonical_block_inner(block, Some(to_block))
10435    }
10436
10437    fn after_canonical_block_inner(
10438        block: BlockRef,
10439        to_block: Option<u64>,
10440    ) -> Result<Self, SubscriberError> {
10441        let from_block = block
10442            .number
10443            .checked_add(1)
10444            .ok_or(SubscriberError::InvalidConfig(
10445                "cannot construct an exclusive backfill after block u64::MAX",
10446            ))?;
10447        Ok(Self {
10448            from_block,
10449            to_block,
10450            retained_anchor: Some(block),
10451        })
10452    }
10453
10454    /// First block included in the backfill.
10455    pub fn start_block(&self) -> u64 {
10456        self.from_block
10457    }
10458
10459    /// Last block included in the backfill, or `None` for provider latest.
10460    pub fn end_block(&self) -> Option<u64> {
10461        self.to_block
10462    }
10463
10464    /// Exact retained start-block identity, when supplied.
10465    pub fn retained_anchor(&self) -> Option<&BlockRef> {
10466        self.retained_anchor.as_ref()
10467    }
10468}
10469
10470/// Opaque generation for one transaction-aware subscriber interest owner.
10471///
10472/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
10473/// reused, including after an aborted stage or a full interest replacement.
10474/// Lifecycle operations require the complete token so a delayed command for an
10475/// older registration cannot affect a replacement using the same [`HandlerId`].
10476#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
10477pub struct SubscriberOwnerEpoch {
10478    owner: HandlerId,
10479    sequence: u64,
10480}
10481
10482/// Delivery audience retained with a subscriber input record.
10483///
10484/// Canonical inputs are forwarded once to the runtime actor and may also name
10485/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
10486/// overlap records that must never be routed through existing canonical
10487/// handlers.
10488#[derive(Clone, Debug, PartialEq, Eq)]
10489#[non_exhaustive]
10490pub enum SubscriberInputScope {
10491    /// One canonical input plus any staged owners that matched at enqueue time.
10492    Canonical {
10493        /// Staged owner epochs that require a buffered copy.
10494        owners: Vec<SubscriberOwnerEpoch>,
10495    },
10496    /// Canonical input whose owner catch-up already delivered selected handler
10497    /// owners. The residual canonical copy must exclude those handlers while
10498    /// remaining authoritative for global chain progress.
10499    CanonicalResidual {
10500        /// Staged epoch owners that still require a buffered copy.
10501        owners: Vec<SubscriberOwnerEpoch>,
10502        /// Active compatibility owners already served by owner catch-up.
10503        excluded: Vec<HandlerId>,
10504    },
10505    /// Input delivered only to the listed staged owners.
10506    OwnerOnly {
10507        /// Exact staged owner epochs receiving the input.
10508        owners: Vec<SubscriberOwnerEpoch>,
10509    },
10510    /// Compatibility owner-only delivery keyed by stable handler id.
10511    OwnerOnlyHandlers {
10512        /// Exact active handlers receiving the catch-up input.
10513        owners: Vec<HandlerId>,
10514    },
10515    /// Flashblock input routed through ordinary matching handlers but applied
10516    /// only to the speculative overlay.
10517    Preconfirmed,
10518}
10519
10520impl SubscriberInputScope {
10521    /// Exact staged owner epochs attached to this input.
10522    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
10523        match self {
10524            Self::Canonical { owners }
10525            | Self::CanonicalResidual { owners, .. }
10526            | Self::OwnerOnly { owners } => owners,
10527            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
10528        }
10529    }
10530
10531    /// Whether this input must be forwarded once through canonical routing.
10532    pub const fn is_canonical(&self) -> bool {
10533        matches!(
10534            self,
10535            Self::Canonical { .. } | Self::CanonicalResidual { .. }
10536        )
10537    }
10538
10539    /// Whether this input belongs only to the disposable preconfirmed overlay.
10540    pub const fn is_preconfirmed(&self) -> bool {
10541        matches!(self, Self::Preconfirmed)
10542    }
10543}
10544
10545/// Reactive input together with its canonical/owner-scoped delivery audience.
10546#[derive(Clone, Debug)]
10547pub struct SubscriberInputRecord<N: Network = Ethereum> {
10548    record: ReactiveInputRecord<N>,
10549    scope: SubscriberInputScope,
10550    preconfirmation_timing: Option<FlashblockIngressTiming>,
10551}
10552
10553impl<N: Network> SubscriberInputRecord<N> {
10554    /// Borrow the reactive input record.
10555    pub const fn record(&self) -> &ReactiveInputRecord<N> {
10556        &self.record
10557    }
10558
10559    /// Delivery audience captured when the record was enqueued.
10560    pub const fn scope(&self) -> &SubscriberInputScope {
10561        &self.scope
10562    }
10563
10564    /// Original typed source ingress when this is a preconfirmed record.
10565    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
10566        self.preconfirmation_timing
10567    }
10568
10569    /// Consume the scoped value into its reactive input record.
10570    pub fn into_record(self) -> ReactiveInputRecord<N> {
10571        self.record
10572    }
10573}
10574
10575impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
10576    type Target = ReactiveInputRecord<N>;
10577
10578    fn deref(&self) -> &Self::Target {
10579        &self.record
10580    }
10581}
10582
10583/// Batch of subscriber inputs with enqueue-time owner provenance.
10584#[derive(Clone, Debug)]
10585pub struct SubscriberInputBatch<N: Network = Ethereum> {
10586    records: Vec<SubscriberInputRecord<N>>,
10587    chain_id: Option<u64>,
10588    chain_controls: Vec<ChainControl>,
10589    preconfirmation_invalidated: bool,
10590    preconfirmation_timing: Option<FlashblockIngressTiming>,
10591}
10592
10593/// Result of polling a scoped subscriber batch against one driver control
10594/// future.
10595#[derive(Debug)]
10596#[non_exhaustive]
10597pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
10598    /// The control future completed first; subscriber delivery remains intact.
10599    Control(C),
10600    /// Subscriber polling completed first.
10601    Batch(Option<SubscriberInputBatch<N>>),
10602}
10603
10604impl<N: Network> SubscriberInputBatch<N> {
10605    /// Borrow every scoped record in delivery order.
10606    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
10607        &self.records
10608    }
10609
10610    /// Consume the batch into its scoped records.
10611    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
10612        self.records
10613    }
10614
10615    /// Ordered chain controls committed after the preceding records.
10616    pub fn chain_controls(&self) -> &[ChainControl] {
10617        &self.chain_controls
10618    }
10619
10620    /// Whether the announcing Flashblocks generation lost continuity before
10621    /// this batch was returned.
10622    pub const fn preconfirmation_invalidated(&self) -> bool {
10623        self.preconfirmation_invalidated
10624    }
10625
10626    /// Earliest typed source ingress contributing to a preconfirmed batch.
10627    pub const fn preconfirmation_timing(&self) -> Option<FlashblockIngressTiming> {
10628        self.preconfirmation_timing
10629    }
10630
10631    /// Consume the scoped subscriber delivery into a runtime-ready batch.
10632    ///
10633    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
10634    /// allowing downstream owner actors to forward a batch without rebuilding
10635    /// subscriber-internal scope metadata.
10636    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
10637        let chain_id = self.chain_id;
10638        let chain_controls = self.chain_controls;
10639        let preconfirmation_timing = self.preconfirmation_timing;
10640        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
10641            self.records.into_iter().map(|scoped| {
10642                let source = scoped.record.context.source;
10643                let (audience, delivery_scope) = match scoped.scope {
10644                    SubscriberInputScope::Canonical { .. } => (
10645                        DeliveryAudience::All,
10646                        if source == InputSource::Backfill {
10647                            DeliveryScope::CanonicalProgress
10648                        } else {
10649                            DeliveryScope::Canonical
10650                        },
10651                    ),
10652                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
10653                        DeliveryAudience::AllExcept(excluded),
10654                        if source == InputSource::Backfill {
10655                            DeliveryScope::CanonicalProgress
10656                        } else {
10657                            DeliveryScope::Canonical
10658                        },
10659                    ),
10660                    SubscriberInputScope::OwnerOnly { owners } => {
10661                        let mut handler_ids = Vec::with_capacity(owners.len());
10662                        for epoch in owners {
10663                            if !handler_ids.contains(epoch.owner()) {
10664                                handler_ids.push(epoch.owner().clone());
10665                            }
10666                        }
10667                        (
10668                            DeliveryAudience::Owners(handler_ids),
10669                            DeliveryScope::OwnerCatchup,
10670                        )
10671                    }
10672                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
10673                        DeliveryAudience::Owners(owners),
10674                        DeliveryScope::OwnerCatchup,
10675                    ),
10676                    SubscriberInputScope::Preconfirmed => {
10677                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
10678                    }
10679                };
10680                (scoped.record, audience, delivery_scope)
10681            }),
10682        )
10683        .with_chain_controls(chain_controls);
10684        if let Some(chain_id) = chain_id {
10685            batch = batch.with_chain_id(chain_id);
10686        }
10687        if let Some(timing) = preconfirmation_timing {
10688            batch = batch.with_preconfirmation_timing(timing);
10689        }
10690        batch
10691    }
10692}
10693
10694impl SubscriberOwnerEpoch {
10695    /// Logical subscriber owner represented by this epoch.
10696    pub const fn owner(&self) -> &HandlerId {
10697        &self.owner
10698    }
10699
10700    /// Monotonic subscriber-local epoch sequence.
10701    pub const fn sequence(&self) -> u64 {
10702        self.sequence
10703    }
10704}
10705
10706/// Catch-up policy applied when staging a transaction-aware interest owner.
10707#[derive(Clone, Debug, PartialEq, Eq)]
10708#[non_exhaustive]
10709pub enum SubscriberOwnerStart {
10710    /// Start with live delivery only.
10711    Live,
10712    /// Start strictly after an already-applied post-block baseline.
10713    ///
10714    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
10715    /// itself is never replayed. Transaction-aware callers explicitly call
10716    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
10717    /// owners never use the legacy lazy-backfill queue.
10718    PostBlock(BlockRef),
10719}
10720
10721/// Transaction state of one epoch-scoped subscriber owner.
10722#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10723#[non_exhaustive]
10724pub enum SubscriberOwnerState {
10725    /// Desired interests and owner-scoped buffering are installed but canonical
10726    /// routing has not yet committed.
10727    Staged,
10728    /// Canonical runtime routing has committed for this owner.
10729    Active,
10730    /// Removal is prepared behind a delivery fence but remains reversible.
10731    Removing,
10732}
10733
10734/// Hash-certified catch-up position reached by one subscriber owner epoch.
10735///
10736/// Progress means every owner-only record through this point has been fetched
10737/// and queued inside the subscriber. It does not mean the downstream actor has
10738/// drained or committed those records; that requires a separate delivery fence.
10739#[derive(Clone, Debug, PartialEq, Eq)]
10740pub struct SubscriberOwnerProgress {
10741    owner: SubscriberOwnerEpoch,
10742    through: BlockRef,
10743}
10744
10745impl SubscriberOwnerProgress {
10746    /// Exact owner epoch whose catch-up was reconciled.
10747    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
10748        &self.owner
10749    }
10750
10751    /// Verified canonical block through which owner input was fetched.
10752    pub const fn through(&self) -> &BlockRef {
10753        &self.through
10754    }
10755}
10756
10757/// Error staging a transaction-aware subscriber owner.
10758#[derive(Debug, thiserror::Error)]
10759#[non_exhaustive]
10760pub enum SubscriberOwnerError {
10761    /// Subscriber configuration or interest validation failed.
10762    #[error(transparent)]
10763    Subscriber(#[from] SubscriberError),
10764    /// The logical owner already has desired interests installed.
10765    #[error("subscriber interest owner `{0}` is already registered")]
10766    AlreadyRegistered(HandlerId),
10767    /// A post-block baseline cannot be advanced to its first unapplied block.
10768    #[error("post-block subscriber baseline {0} has no following block")]
10769    PostBlockOverflow(u64),
10770    /// The monotonic subscriber owner epoch sequence was exhausted.
10771    #[error("subscriber owner epoch sequence exhausted")]
10772    EpochExhausted,
10773    /// The exact owner epoch is unknown or no longer staged.
10774    #[error("subscriber owner epoch is not staged")]
10775    NotStaged,
10776    /// Live-only staging has no historical baseline to reconcile.
10777    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
10778    MissingBaseline,
10779    /// Post-block reconciliation currently covers log interests only.
10780    #[error("post-block subscriber owners support log interests only")]
10781    UnsupportedPostBlockInterest,
10782    /// The target block was absent from the provider.
10783    #[error("subscriber reconcile target block {0} was not found")]
10784    BlockUnavailable(u64),
10785    /// The provider's canonical identity did not match the requested target.
10786    #[error(
10787        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
10788    )]
10789    BlockMismatch {
10790        /// Requested block number.
10791        expected_number: u64,
10792        /// Requested block hash.
10793        expected_hash: B256,
10794        /// Provider block number.
10795        actual_number: u64,
10796        /// Provider block hash.
10797        actual_hash: B256,
10798    },
10799    /// A reconcile target was older than the retained baseline/progress.
10800    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
10801    ProgressRegression {
10802        /// Retained baseline or progress block.
10803        current: u64,
10804        /// Rejected target block.
10805        target: u64,
10806    },
10807    /// A reconcile attempted to replace a retained block identity at the same
10808    /// height or cross an immediate parent that does not extend it.
10809    #[error(
10810        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
10811    )]
10812    ProgressConflict {
10813        /// Retained baseline or progress block number.
10814        number: u64,
10815        /// Retained baseline or progress block hash.
10816        current_hash: B256,
10817        /// Conflicting target hash or immediate parent hash.
10818        target_hash: B256,
10819    },
10820    /// A provider returned a malformed or out-of-range catch-up log.
10821    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
10822    InvalidBackfillLog(&'static str),
10823}
10824
10825/// Extension trait for subscribers that can add and remove handler-owned
10826/// interests incrementally.
10827///
10828/// [`EventSubscriber::register_interests`] remains the full-replacement setup
10829/// API. Implement this trait when a subscriber can preserve unrelated live
10830/// sources and delivery state while one handler's interests are added or
10831/// removed. Implementations should make owner *replacement* continuity-safe:
10832/// updating an owner's interests must not silently discard delivery progress
10833/// the previous interests had already established (the in-crate
10834/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
10835/// changed filter shapes and automatically backfills the gap). Every mutating
10836/// operation is also a commit boundary: returning `Ok` means the new desired
10837/// state is authoritative, while errors or cancellation must preserve the
10838/// previous state or reconcile before exposing the uncommitted change.
10839pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
10840    /// Atomically add or replace several owners in one desired-state revision.
10841    ///
10842    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
10843    /// boundary for the complete set; an error or cancellation must leave the
10844    /// previously committed owner topology authoritative. Durable remote
10845    /// subscribers should override this method so bootstrap creates one service
10846    /// revision and one activation barrier rather than one barrier per owner.
10847    ///
10848    /// # Errors
10849    ///
10850    /// The returned operation reports [`SubscriberError::Unsupported`] by
10851    /// default, or an implementation-specific validation or commit failure.
10852    fn upsert_interest_owners(
10853        &mut self,
10854        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10855    ) -> SubscriberOperation<'_, ()> {
10856        Box::pin(async {
10857            Err(SubscriberError::Unsupported(
10858                "subscriber does not implement atomic bulk owner upsert",
10859            ))
10860        })
10861    }
10862
10863    /// Atomically replace the complete engine-managed owner topology without
10864    /// requesting history.
10865    ///
10866    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
10867    /// stale owners, queued delivery, and dedupe/source state from the prior
10868    /// topology must not survive a successful replacement. Errors and dropped
10869    /// futures leave the prior committed topology authoritative.
10870    ///
10871    /// # Errors
10872    ///
10873    /// The returned operation reports [`SubscriberError::Unsupported`] by
10874    /// default, or an implementation-specific validation or commit failure.
10875    fn replace_interest_owners(
10876        &mut self,
10877        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10878    ) -> SubscriberOperation<'_, ()> {
10879        Box::pin(async {
10880            Err(SubscriberError::Unsupported(
10881                "subscriber does not implement atomic exact owner replacement",
10882            ))
10883        })
10884    }
10885
10886    /// Atomically replace the complete owner set and schedule one global
10887    /// historical log backfill in the same desired-state revision.
10888    ///
10889    /// This is the continuity-safe bootstrap operation for a runtime that has
10890    /// already processed canonical state while the subscriber's owner state is
10891    /// new or may have been lost. Implementations must commit the complete
10892    /// owner topology and all required historical work together: returning an
10893    /// error or dropping the future must leave the previously committed state
10894    /// authoritative. The default is deliberately unsupported rather than a
10895    /// sequence of partially committed single-owner updates.
10896    /// Historical records must be delivered through canonical global routing
10897    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
10898    /// owner catch-up, so their effects participate in the normal rollback
10899    /// journal before the source certifies the cutover. Base/unowned interests
10900    /// are replaced by this complete engine-managed topology. Any owner absent
10901    /// from `owners` must be removed together with its queued owner-only work, which closes
10902    /// the crash window where a subscriber committed registration but the
10903    /// runtime process died before installing the corresponding handler.
10904    ///
10905    /// # Errors
10906    ///
10907    /// The returned operation reports [`SubscriberError::Unsupported`] by
10908    /// default, or a backfill, validation, transport, or atomic-commit failure.
10909    fn replace_interest_owners_with_global_backfill(
10910        &mut self,
10911        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10912        _backfill: SubscriberBackfill,
10913    ) -> SubscriberOperation<'_, ()> {
10914        Box::pin(async {
10915            Err(SubscriberError::Unsupported(
10916                "subscriber does not implement atomic owner replacement with global backfill",
10917            ))
10918        })
10919    }
10920
10921    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
10922    /// commit boundary.
10923    ///
10924    /// Implementations must leave the previously committed owner state
10925    /// authoritative when the operation returns an error or is cancelled before
10926    /// completion.
10927    ///
10928    /// # Errors
10929    ///
10930    /// The returned operation reports [`SubscriberError`] when the owner update
10931    /// cannot be validated or committed.
10932    fn add_interest_owner(
10933        &mut self,
10934        owner: HandlerId,
10935        interests: &[ReactiveInterest<N>],
10936    ) -> SubscriberOperation<'_, ()>;
10937
10938    /// Add or replace owner interests and schedule log backfill for that owner,
10939    /// awaiting the subscriber's commit boundary.
10940    ///
10941    /// # Errors
10942    ///
10943    /// The returned operation reports [`SubscriberError`] when the owner update
10944    /// or requested backfill cannot be validated or committed.
10945    fn add_interest_owner_with_backfill(
10946        &mut self,
10947        owner: HandlerId,
10948        interests: &[ReactiveInterest<N>],
10949        backfill: SubscriberBackfill,
10950    ) -> SubscriberOperation<'_, ()>;
10951
10952    /// Add a handler discovered at retained canonical block `C` without
10953    /// opening a gap while registration commits.
10954    ///
10955    /// The subscriber must subscribe/adopt the new desired state first, then
10956    /// expose the new owner's matching records from `C` as owner catch-up and
10957    /// expose `C + 1` through the activation head as one globally ordered
10958    /// canonical catch-up over the complete active interest union. This split
10959    /// is deliberate: the runtime already has a rollback entry for `C`, while
10960    /// later blocks must run every handler and create normal canonical journal
10961    /// entries. Errors/cancellation preserve the prior committed topology.
10962    /// Implementations that cannot uphold this coordinated transaction must
10963    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
10964    ///
10965    /// # Errors
10966    ///
10967    /// The returned operation reports [`SubscriberError::Unsupported`] by
10968    /// default, or a canonical-anchor, transport, or atomic-commit failure.
10969    fn add_interest_owner_with_canonical_catchup(
10970        &mut self,
10971        _owner: HandlerId,
10972        _interests: &[ReactiveInterest<N>],
10973        _retained: BlockRef,
10974    ) -> SubscriberOperation<'_, ()> {
10975        Box::pin(async {
10976            Err(SubscriberError::Unsupported(
10977                "subscriber does not implement coordinated canonical owner catch-up",
10978            ))
10979        })
10980    }
10981
10982    /// Remove one owner's interests, preserving unrelated interests, and await
10983    /// acknowledgement that the removal committed.
10984    ///
10985    /// On error the owner must remain authoritative, so the runtime handler is
10986    /// not removed while subscriber delivery may still target it.
10987    ///
10988    /// # Errors
10989    ///
10990    /// The returned operation reports [`SubscriberError`] when the removal
10991    /// cannot be committed while preserving unrelated owners.
10992    fn remove_interest_owner(
10993        &mut self,
10994        owner: &HandlerId,
10995    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
10996
10997    /// Borrow the interests currently owned by `owner`.
10998    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
10999}
11000
11001/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
11002/// subscribe-ingest lifecycle.
11003///
11004/// The engine treats the runtime registry as the single source of truth for
11005/// handler lifecycle: [`register_handler`](Self::register_handler) and
11006/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
11007/// subscriber interests as one operation, keyed by the handler's stable
11008/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
11009/// has journaled canonical block *N*, a newly registered handler is live-adopted,
11010/// replayed owner-only at *N*, and then caught up globally with every handler
11011/// from *N + 1* through activation. A factory-discovered pool therefore misses
11012/// none of its own logs without making later history owner-local and
11013/// unrollbackable. The subscriber must absorb overlap that crosses batch
11014/// boundaries; the runtime validates and merges duplicate representations only
11015/// within one [`ReactiveInputBatch`].
11016///
11017/// Registration methods by intent:
11018///
11019/// | Method | Backfill |
11020/// |---|---|
11021/// | [`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) |
11022/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
11023/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
11024///
11025/// Unregistering a handler stops future subscription routing and runtime
11026/// decode for that handler; it deliberately does not evict [`EvmCache`] state
11027/// or undo runtime side effects. See
11028/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
11029/// recipe.
11030///
11031/// The runtime and subscriber stay independently accessible through
11032/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
11033/// for advanced use. One caution: avoid calling
11034/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
11035/// an engine-managed subscriber — implementations may clear owner-scoped
11036/// bookkeeping, after which per-handler unregistration no longer releases the
11037/// handler's transport subscriptions. To bootstrap the subscriber from a
11038/// runtime that already has handlers, use
11039/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
11040/// one owner per handler instead of one unowned blob.
11041pub struct ReactiveEngine<S, N: Network = Ethereum> {
11042    runtime: ReactiveRuntime<N>,
11043    subscriber: S,
11044    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
11045    pending_checkpoint: Option<PendingCheckpoint<N>>,
11046    last_checkpoint_block: Option<DurableCheckpointBlock>,
11047    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
11048    last_checkpoint_delivery_witness: Option<B256>,
11049    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
11050    checkpoint_identity: Option<DurableCheckpointIdentity>,
11051}
11052
11053struct PendingAcknowledgement<N: Network> {
11054    token: SubscriberDeliveryToken,
11055    report: ReactiveBatchReport<N>,
11056}
11057
11058struct PendingCheckpoint<N: Network> {
11059    metadata: DurableCheckpointMetadata,
11060    delivery_token: Option<SubscriberDeliveryToken>,
11061    report: ReactiveBatchReport<N>,
11062    saved_to: Option<PathBuf>,
11063    staged_generation: u64,
11064}
11065
11066struct CheckpointStage<N: Network> {
11067    incoming_block: Option<DurableCheckpointBlock>,
11068    delivery_token: Option<SubscriberDeliveryToken>,
11069    delivery_witness: Option<B256>,
11070    subscriber_checkpoint: Option<SubscriberCheckpoint>,
11071    staged_generation: u64,
11072    report: ReactiveBatchReport<N>,
11073}
11074
11075struct DurableResumePlan {
11076    runtime: DurableRuntimeRestorePlan,
11077    position: SubscriberResumePosition,
11078    delivery_witness: Option<B256>,
11079}
11080
11081enum HandlerRegistrationCatchup {
11082    LiveOnly,
11083    OwnerBackfill(SubscriberBackfill),
11084    CoordinatedCanonical(BlockRef),
11085}
11086
11087const DELIVERY_WITNESS_VERSION: u32 = 1;
11088const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
11089
11090#[derive(serde::Serialize)]
11091struct DeliveryWitnessEnvelope<'a> {
11092    version: u32,
11093    chain_id: Option<u64>,
11094    records: Vec<DeliveryRecordWitness<'a>>,
11095    chain_controls: &'a [ChainControl],
11096    subscriber_checkpoint: Option<&'a [u8]>,
11097    payload_commitment: Option<B256>,
11098}
11099
11100#[derive(serde::Serialize)]
11101struct DeliveryRecordWitness<'a> {
11102    identity: ReactiveInputIdentity,
11103    context: &'a ReactiveContext,
11104    audience: &'a DeliveryAudience,
11105    scope: DeliveryScope,
11106    payload: DeliveryPayloadWitness<'a>,
11107}
11108
11109#[derive(serde::Serialize)]
11110enum DeliveryPayloadWitness<'a> {
11111    /// Logs are the primary state-bearing event representation, so retain every
11112    /// RPC payload field in addition to the validated identity/context.
11113    Log {
11114        address: Address,
11115        topics: &'a [B256],
11116        data: &'a Bytes,
11117        block_hash: Option<B256>,
11118        block_number: Option<u64>,
11119        block_timestamp: Option<u64>,
11120        transaction_hash: Option<B256>,
11121        transaction_index: Option<u64>,
11122        log_index: Option<u64>,
11123        removed: bool,
11124    },
11125    /// Network-generic response bodies do not expose one stable complete serde
11126    /// contract. Their validated identity/context are witnessed here; batches
11127    /// containing headers, full blocks, or hydrated transactions additionally
11128    /// require the source's exact canonical wire-payload commitment. A generic
11129    /// header response can expose a supplied hash without proving that every
11130    /// handler-visible inner field recomputes to it.
11131    IdentityCommitted,
11132}
11133
11134fn durable_delivery_witness<N: Network>(
11135    batch: &ReactiveInputBatch<N>,
11136) -> Result<B256, ReactiveEngineError> {
11137    let requires_payload_commitment = batch.records.iter().any(|record| {
11138        matches!(
11139            &record.input,
11140            ReactiveInput::BlockHeader(_)
11141                | ReactiveInput::FullBlock(_)
11142                | ReactiveInput::PendingTx(_)
11143        )
11144    });
11145    if requires_payload_commitment && batch.payload_commitment.is_none() {
11146        return Err(ReactiveEngineError::MissingPayloadCommitment);
11147    }
11148    let records = batch
11149        .records
11150        .iter()
11151        .enumerate()
11152        .map(|(index, record)| {
11153            let payload = match &record.input {
11154                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
11155                    address: log.address(),
11156                    topics: log.topics(),
11157                    data: &log.inner.data.data,
11158                    block_hash: log.block_hash,
11159                    block_number: log.block_number,
11160                    block_timestamp: log.block_timestamp,
11161                    transaction_hash: log.transaction_hash,
11162                    transaction_index: log.transaction_index,
11163                    log_index: log.log_index,
11164                    removed: log.removed,
11165                },
11166                ReactiveInput::BlockHeader(_)
11167                | ReactiveInput::FullBlock(_)
11168                | ReactiveInput::PendingTxHash(_)
11169                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
11170            };
11171            Ok(DeliveryRecordWitness {
11172                identity: record.validated_identity()?,
11173                context: &record.context,
11174                audience: batch
11175                    .record_audience(index)
11176                    .expect("enumerated record always has an audience"),
11177                scope: batch
11178                    .record_delivery_scope(index)
11179                    .expect("enumerated record always has a delivery scope"),
11180                payload,
11181            })
11182        })
11183        .collect::<Result<Vec<_>, ReactiveError>>()?;
11184    let envelope = DeliveryWitnessEnvelope {
11185        version: DELIVERY_WITNESS_VERSION,
11186        chain_id: batch.chain_id,
11187        records,
11188        chain_controls: &batch.chain_controls,
11189        subscriber_checkpoint: batch
11190            .subscriber_checkpoint
11191            .as_ref()
11192            .map(SubscriberCheckpoint::as_bytes),
11193        payload_commitment: batch
11194            .payload_commitment
11195            .as_ref()
11196            .map(SubscriberPayloadCommitment::digest),
11197    };
11198    let encoded = bincode::DefaultOptions::new()
11199        .with_fixint_encoding()
11200        .serialize(&envelope)
11201        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
11202    let mut witness = Keccak256::new();
11203    witness.update(DELIVERY_WITNESS_DOMAIN);
11204    witness.update(encoded);
11205    Ok(witness.finalize())
11206}
11207
11208impl<S, N> ReactiveEngine<S, N>
11209where
11210    N: Network,
11211    S: EventSubscriber<N>,
11212{
11213    /// Bind a runtime and subscriber.
11214    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
11215        Self {
11216            runtime,
11217            subscriber,
11218            pending_acknowledgement: None,
11219            pending_checkpoint: None,
11220            last_checkpoint_block: None,
11221            last_checkpoint_delivery_token: None,
11222            last_checkpoint_delivery_witness: None,
11223            last_subscriber_checkpoint: None,
11224            checkpoint_identity: None,
11225        }
11226    }
11227
11228    /// Split the engine into its runtime and subscriber parts when no commit is
11229    /// pending.
11230    ///
11231    /// A failed delivery acknowledgement or durable checkpoint commit remains
11232    /// live protocol state: dropping it would allow the caller to lose the
11233    /// already-applied report/token pair and poll past an uncommitted batch.
11234    /// In that case this returns the intact engine so the caller can repair the
11235    /// dependency and retry through the normal ingestion method.
11236    ///
11237    /// # Errors
11238    ///
11239    /// Returns the intact boxed engine when an acknowledgement or checkpoint
11240    /// commit is pending.
11241    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
11242        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
11243            return Err(Box::new(self));
11244        }
11245        Ok((self.runtime, self.subscriber))
11246    }
11247
11248    fn durable_resume_plan(
11249        &self,
11250        metadata: &DurableCheckpointMetadata,
11251    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
11252        if !self.subscriber.capabilities().supports_durable_replay() {
11253            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11254        }
11255        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
11256        if !self.runtime.is_pristine_for_checkpoint_restore()
11257            || self.pending_acknowledgement.is_some()
11258            || self.pending_checkpoint.is_some()
11259            || self.last_checkpoint_block.is_some()
11260            || self.last_checkpoint_delivery_token.is_some()
11261            || self.last_checkpoint_delivery_witness.is_some()
11262            || self.last_subscriber_checkpoint.is_some()
11263            || self.checkpoint_identity.is_some()
11264        {
11265            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11266        }
11267
11268        let block = BlockRef {
11269            number: metadata.block.number,
11270            hash: metadata.block.hash,
11271            parent_hash: metadata.block.parent_hash,
11272            timestamp: metadata.block.timestamp,
11273        };
11274        let runtime = match metadata.runtime_checkpoint.as_deref() {
11275            Some(bytes) => self
11276                .runtime
11277                .plan_durable_checkpoint_restore(bytes, &block)?,
11278            None => DurableRuntimeRestorePlan {
11279                checkpoint: None,
11280                fallback_history: (self.runtime.config.journal_depth > 0)
11281                    .then_some(block)
11282                    .into_iter()
11283                    .collect(),
11284            },
11285        };
11286        let delivery_token = metadata
11287            .delivery_token
11288            .clone()
11289            .map(SubscriberDeliveryToken::new);
11290        let subscriber_checkpoint = metadata
11291            .subscriber_checkpoint
11292            .clone()
11293            .map(SubscriberCheckpoint::new);
11294        let position = SubscriberResumePosition::new(
11295            metadata.identity.chain_id,
11296            block,
11297            runtime.canonical_history(),
11298            delivery_token,
11299            subscriber_checkpoint,
11300        );
11301        Ok(DurableResumePlan {
11302            runtime,
11303            position,
11304            delivery_witness: metadata.delivery_witness,
11305        })
11306    }
11307
11308    /// Preview the exact subscriber position a durable restore will install.
11309    ///
11310    /// This read-only step exists for durable subscribers that must complete
11311    /// asynchronous source or transport preparation before the engine invokes
11312    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
11313    /// and validates the core runtime checkpoint, applies this runtime's
11314    /// configured journal retention to the preview, and returns the same
11315    /// [`SubscriberResumePosition`] that
11316    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11317    /// will later pass to the subscriber.
11318    ///
11319    /// Call this on the same fresh engine that will perform the restore. After
11320    /// subscriber preparation completes, pass the identical `metadata` to
11321    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
11322    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
11323    /// without mutating engine runtime or checkpoint state in between. The
11324    /// checkpoint identity and, for non-finalized state, its canonical block
11325    /// must still be validated by the caller before external preparation.
11326    ///
11327    /// This method does not mutate the runtime, subscriber, or checkpoint
11328    /// bookkeeping.
11329    ///
11330    /// # Errors
11331    ///
11332    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11333    /// durable, its chain identity conflicts with the checkpoint, the engine is
11334    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
11335    /// or internally inconsistent.
11336    pub fn preview_durable_resume_position(
11337        &self,
11338        metadata: &DurableCheckpointMetadata,
11339    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
11340        Ok(self.durable_resume_plan(metadata)?.position)
11341    }
11342
11343    /// Resume delivery bookkeeping and canonical continuity from a cache
11344    /// checkpoint that has already been identity- and hash-validated and
11345    /// restored into [`EvmCache`].
11346    ///
11347    /// Call this on a fresh engine. The anchor has no rollback effects of its
11348    /// own: it represents the state baseline embodied by the checkpoint, while
11349    /// newly ingested blocks are journaled normally above it.
11350    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
11351    /// restoring an ephemeral stream would claim a restart guarantee it cannot
11352    /// uphold and is rejected before cache or runtime mutation.
11353    ///
11354    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
11355    /// when the cache has not yet been restored: that helper rolls the cache
11356    /// back as well if runtime or subscriber activation fails.
11357    ///
11358    /// # Errors
11359    ///
11360    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11361    /// durable, chain identity conflicts, the runtime is not pristine, stored
11362    /// runtime state is invalid, or the subscriber rejects the restored
11363    /// position. Runtime state is restored on subscriber failure.
11364    pub fn resume_from_durable_checkpoint(
11365        &mut self,
11366        metadata: &DurableCheckpointMetadata,
11367    ) -> Result<(), ReactiveCheckpointRestoreError> {
11368        let plan = self.durable_resume_plan(metadata)?;
11369        let prior_runtime = self.runtime.checkpoint_state();
11370
11371        let DurableResumePlan {
11372            runtime,
11373            position,
11374            delivery_witness,
11375        } = plan;
11376        self.runtime.apply_durable_checkpoint_restore(runtime);
11377        self.runtime.coverage_head = Some(position.coverage_head);
11378        if let Err(error) = self.subscriber.restore_position(&position) {
11379            self.runtime.restore_state(prior_runtime);
11380            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
11381        }
11382        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
11383            self.runtime.restore_state(prior_runtime);
11384            return Err(error);
11385        }
11386        self.last_checkpoint_block = Some(metadata.block.clone());
11387        self.last_checkpoint_delivery_token = position.delivery_token;
11388        self.last_checkpoint_delivery_witness = delivery_witness;
11389        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
11390        self.checkpoint_identity = Some(metadata.identity.clone());
11391        Ok(())
11392    }
11393
11394    /// Atomically restore cache, runtime, and subscriber position from one
11395    /// validated durable checkpoint.
11396    ///
11397    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
11398    /// block against an authoritative RPC source before calling this method when
11399    /// the block is not finalized. Identity, cache-chain, runtime-state, and
11400    /// subscriber failures leave the cache and engine runtime unchanged. The
11401    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
11402    /// It must advertise [`SubscriberCapability::DurableReplay`].
11403    ///
11404    /// # Errors
11405    ///
11406    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
11407    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
11408    /// position-restore failures. Cache and runtime state remain unchanged.
11409    pub fn restore_durable_checkpoint(
11410        &mut self,
11411        cache: &mut EvmCache,
11412        loaded: LoadedDurableCheckpoint,
11413        expected: &DurableCheckpointIdentity,
11414    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
11415        if !self.subscriber.capabilities().supports_durable_replay() {
11416            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11417        }
11418        self.ensure_subscriber_restore_chain(expected.chain_id)?;
11419        if !self.runtime.is_pristine_for_checkpoint_restore()
11420            || self.pending_acknowledgement.is_some()
11421            || self.pending_checkpoint.is_some()
11422            || self.last_checkpoint_block.is_some()
11423            || self.last_checkpoint_delivery_token.is_some()
11424            || self.last_checkpoint_delivery_witness.is_some()
11425            || self.last_subscriber_checkpoint.is_some()
11426            || self.checkpoint_identity.is_some()
11427        {
11428            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11429        }
11430
11431        let prior_cache = EvmCacheStateSnapshot::capture(cache);
11432        let metadata = loaded.restore_into(cache, expected)?;
11433        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
11434            prior_cache.restore(cache);
11435            return Err(error);
11436        }
11437        Ok(metadata)
11438    }
11439
11440    /// Borrow the runtime.
11441    pub fn runtime(&self) -> &ReactiveRuntime<N> {
11442        &self.runtime
11443    }
11444
11445    /// Mutably borrow the runtime.
11446    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
11447        &mut self.runtime
11448    }
11449
11450    /// Borrow the subscriber.
11451    pub fn subscriber(&self) -> &S {
11452        &self.subscriber
11453    }
11454
11455    /// Mutably borrow the subscriber.
11456    pub fn subscriber_mut(&mut self) -> &mut S {
11457        &mut self.subscriber
11458    }
11459
11460    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
11461    /// cold-start baseline.
11462    ///
11463    /// The cache must use the exact canonical hash selector and block-number
11464    /// context named by `baseline`; when the baseline includes a timestamp, the
11465    /// cache timestamp must match too. Cache, baseline, and any already-resolved
11466    /// subscriber identity must name the same chain. No delivery or checkpoint
11467    /// commit may be pending. After this succeeds, call
11468    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
11469    /// before polling: it exact-replaces subscriber owners and begins event
11470    /// catch-up at `C + 1`.
11471    ///
11472    /// # Errors
11473    ///
11474    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
11475    /// is active or already has a conflicting baseline, cache/subscriber chain
11476    /// identity differs, or the cache is not pinned to the exact baseline.
11477    pub fn adopt_canonical_baseline(
11478        &mut self,
11479        cache: &EvmCache,
11480        baseline: ReactiveCanonicalBaseline,
11481    ) -> Result<(), ReactiveEngineError> {
11482        if self.pending_acknowledgement.is_some()
11483            || self.pending_checkpoint.is_some()
11484            || self.last_checkpoint_block.is_some()
11485            || self.last_checkpoint_delivery_token.is_some()
11486            || self.last_checkpoint_delivery_witness.is_some()
11487            || self.last_subscriber_checkpoint.is_some()
11488            || self.checkpoint_identity.is_some()
11489        {
11490            return Err(ReactiveBaselineError::ActiveRuntime.into());
11491        }
11492        // Establish deterministic lifecycle/idempotency semantics before
11493        // consulting mutable cache context. A conflicting repeat is a runtime
11494        // baseline conflict even if the caller also repointed the cache.
11495        self.runtime
11496            .validate_canonical_baseline_adoption(baseline.block)?;
11497        if baseline.chain_id != cache.chain_id() {
11498            return Err(ReactiveBaselineError::CacheChainMismatch {
11499                baseline_chain_id: baseline.chain_id,
11500                cache_chain_id: cache.chain_id(),
11501            }
11502            .into());
11503        }
11504        self.ensure_subscriber_chain(cache)?;
11505        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
11506        let context_matches = cache.block_number() == Some(baseline.block.number)
11507            && baseline
11508                .block
11509                .timestamp
11510                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
11511        if cache.block() != exact_selector || !context_matches {
11512            return Err(ReactiveBaselineError::CacheBlockMismatch {
11513                number: baseline.block.number,
11514                hash: baseline.block.hash,
11515            }
11516            .into());
11517        }
11518        self.runtime.adopt_canonical_baseline(baseline.block)?;
11519        Ok(())
11520    }
11521
11522    /// Poll the subscriber for the next batch without ingesting it.
11523    ///
11524    /// This low-level escape hatch is unavailable while the engine owes an
11525    /// acknowledgement or checkpoint commit. Callers that use it must return
11526    /// any subscriber-owned delivery metadata through a combined
11527    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
11528    /// rejects that metadata so it cannot be discarded accidentally.
11529    ///
11530    /// # Errors
11531    ///
11532    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
11533    /// is pending or subscriber and cache chain identities conflict.
11534    pub fn next_batch(
11535        &mut self,
11536        cache: &EvmCache,
11537    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
11538        if self.pending_checkpoint.is_some() {
11539            return Err(ReactiveEngineError::PendingCheckpointCommit);
11540        }
11541        if self.pending_acknowledgement.is_some() {
11542            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11543        }
11544        self.ensure_subscriber_chain(cache)?;
11545        Ok(self.subscriber.next_batch())
11546    }
11547
11548    /// Ingest one already-polled batch through the runtime (direct effects
11549    /// only; surfaced resync requests are reported, not executed).
11550    ///
11551    /// # Errors
11552    ///
11553    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11554    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11555    /// runtime ingestion fails.
11556    pub fn ingest_batch(
11557        &mut self,
11558        cache: &mut EvmCache,
11559        batch: ReactiveInputBatch<N>,
11560    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11561        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11562        Ok(self.runtime.ingest_batch(cache, batch)?)
11563    }
11564
11565    /// Ingest one already-polled batch and execute the storage/account resyncs
11566    /// it surfaces, exactly like
11567    /// [`ReactiveRuntime::ingest_batch_with_resync`].
11568    ///
11569    /// # Errors
11570    ///
11571    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11572    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11573    /// runtime ingestion fails.
11574    pub fn ingest_batch_with_resync(
11575        &mut self,
11576        cache: &mut EvmCache,
11577        batch: ReactiveInputBatch<N>,
11578    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11579        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11580        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
11581    }
11582
11583    fn ensure_raw_ingest_is_safe(
11584        &self,
11585        cache: &EvmCache,
11586        batch: &ReactiveInputBatch<N>,
11587    ) -> Result<(), ReactiveEngineError> {
11588        if self.pending_checkpoint.is_some() {
11589            return Err(ReactiveEngineError::PendingCheckpointCommit);
11590        }
11591        if self.pending_acknowledgement.is_some() {
11592            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11593        }
11594        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
11595            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
11596        }
11597        self.ensure_subscriber_chain(cache)?;
11598        Ok(())
11599    }
11600
11601    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
11602        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11603            && subscriber_chain_id != cache.chain_id()
11604        {
11605            return Err(ReactiveEngineError::SubscriberChainMismatch {
11606                subscriber_chain_id,
11607                cache_chain_id: cache.chain_id(),
11608            });
11609        }
11610        Ok(())
11611    }
11612
11613    fn ensure_subscriber_restore_chain(
11614        &self,
11615        checkpoint_chain_id: u64,
11616    ) -> Result<(), ReactiveCheckpointRestoreError> {
11617        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11618            && subscriber_chain_id != checkpoint_chain_id
11619        {
11620            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
11621                subscriber_chain_id,
11622                checkpoint_chain_id,
11623            });
11624        }
11625        Ok(())
11626    }
11627
11628    /// Poll the subscriber once and ingest the returned batch when present
11629    /// (direct effects only).
11630    ///
11631    /// # Errors
11632    ///
11633    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11634    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11635    /// delivery-acknowledgement failure. A failed acknowledgement remains
11636    /// pending and is retried before polling again.
11637    pub async fn next_ingest(
11638        &mut self,
11639        cache: &mut EvmCache,
11640    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11641        self.ensure_subscriber_chain(cache)?;
11642        if self.pending_checkpoint.is_some() {
11643            return Err(ReactiveEngineError::PendingCheckpointCommit);
11644        }
11645        if self.pending_acknowledgement.is_some() {
11646            return self.commit_pending_acknowledgement().await.map(Some);
11647        }
11648        let batch = self.subscriber.next_batch().await?;
11649        self.ensure_subscriber_chain(cache)?;
11650        let Some(mut batch) = batch else {
11651            return Ok(None);
11652        };
11653        let delivery_token = batch.take_delivery_token();
11654        let report = self.runtime.ingest_batch(cache, batch)?;
11655        self.stage_or_return_acknowledgement(delivery_token, report)
11656            .await
11657    }
11658
11659    /// Poll the subscriber once and ingest the returned batch with resync
11660    /// execution — the loop shape for consumers that rely on coverage-gap
11661    /// repair (root-gate resyncs, handler-requested re-reads).
11662    ///
11663    /// # Errors
11664    ///
11665    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11666    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11667    /// delivery-acknowledgement failure. A failed acknowledgement remains
11668    /// pending and is retried before polling again.
11669    pub async fn next_ingest_with_resync(
11670        &mut self,
11671        cache: &mut EvmCache,
11672    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11673        self.ensure_subscriber_chain(cache)?;
11674        if self.pending_checkpoint.is_some() {
11675            return Err(ReactiveEngineError::PendingCheckpointCommit);
11676        }
11677        if self.pending_acknowledgement.is_some() {
11678            return self.commit_pending_acknowledgement().await.map(Some);
11679        }
11680        let batch = self.subscriber.next_batch().await?;
11681        self.ensure_subscriber_chain(cache)?;
11682        let Some(mut batch) = batch else {
11683            return Ok(None);
11684        };
11685        let delivery_token = batch.take_delivery_token();
11686        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
11687        self.stage_or_return_acknowledgement(delivery_token, report)
11688            .await
11689    }
11690
11691    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
11692    ///
11693    /// The ordering is strict: subscriber acknowledgement is never attempted
11694    /// until the complete cache checkpoint is synced. If checkpointing or
11695    /// acknowledgement fails, the in-memory pending commit is retried before
11696    /// any later batch is polled, so a transient disk failure cannot cause the
11697    /// already-applied batch to execute twice in the same process. Across a
11698    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11699    /// uses the stored delivery token and delivery witness to recognize and
11700    /// acknowledge an identical replay without re-ingestion. Reusing a token
11701    /// for different input or cursor state fails closed. Mutating the cache while
11702    /// a commit is pending also fails closed rather than binding newer state to
11703    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
11704    /// that cannot be proven from the retained effect journal is rejected before
11705    /// mutation/save/ACK; configure
11706    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
11707    /// Hooks are dispatched only after checkpoint staging
11708    /// succeeds, but remain in-process observers rather than a durable outbox;
11709    /// see [`ReactiveHook`]. The subscriber must advertise
11710    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
11711    /// rejected before polling.
11712    ///
11713    /// # Errors
11714    ///
11715    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
11716    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
11717    /// an incompatible state, polling or ingestion fails, complete rollback
11718    /// proof is unavailable, the cache changes after staging, persistence
11719    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
11720    /// retained for retry before another poll.
11721    pub async fn next_ingest_checkpointed(
11722        &mut self,
11723        cache: &mut EvmCache,
11724        store: &DurableCheckpointStore,
11725        identity: &DurableCheckpointIdentity,
11726    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11727        if !self.subscriber.capabilities().supports_durable_replay() {
11728            return Err(ReactiveEngineError::SubscriberNotDurable);
11729        }
11730        self.ensure_subscriber_chain(cache)?;
11731        if self.pending_acknowledgement.is_some() {
11732            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11733        }
11734        self.ensure_checkpoint_identity(cache, identity)?;
11735        if self.pending_checkpoint.is_some() {
11736            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11737        }
11738
11739        let batch = self.subscriber.next_batch().await?;
11740        self.ensure_subscriber_chain(cache)?;
11741        let Some(mut batch) = batch else {
11742            return Ok(None);
11743        };
11744        if batch_preconfirmation(&batch)?.is_some() {
11745            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11746        }
11747        self.runtime.discard_preconfirmed_branch(cache);
11748        let delivery_witness = batch
11749            .delivery_token()
11750            .map(|_| durable_delivery_witness(&batch))
11751            .transpose()?;
11752        let delivery_token = batch.take_delivery_token();
11753        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11754        if let (Some(replay_token), Some(committed_token)) = (
11755            delivery_token.as_ref(),
11756            self.last_checkpoint_delivery_token.as_ref(),
11757        ) && replay_token == committed_token
11758        {
11759            let committed_witness = self
11760                .last_checkpoint_delivery_witness
11761                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11762            if delivery_witness != Some(committed_witness) {
11763                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11764            }
11765            self.subscriber
11766                .acknowledge_delivery(replay_token.clone())
11767                .await
11768                .map_err(ReactiveEngineError::Acknowledgement)?;
11769            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11770        }
11771
11772        self.ensure_checkpointable_reorgs(&batch)?;
11773
11774        let incoming_block = latest_canonical_batch_block(&batch);
11775        let cache_state = EvmCacheStateSnapshot::capture(cache);
11776        let runtime_state = self.runtime.checkpoint_state();
11777        let report = match self.runtime.ingest_batch_direct(cache, batch) {
11778            Ok(report) => report,
11779            Err(error) => {
11780                cache_state.restore(cache);
11781                self.runtime.restore_transaction_state(runtime_state);
11782                return Err(error.into());
11783            }
11784        };
11785        let reports = report.reports.clone();
11786        let stage = CheckpointStage {
11787            incoming_block,
11788            delivery_token,
11789            delivery_witness,
11790            subscriber_checkpoint,
11791            staged_generation: cache.snapshot_generation(),
11792            report,
11793        };
11794        if let Err(error) = self.stage_checkpoint(identity, stage) {
11795            cache_state.restore(cache);
11796            self.runtime.restore_transaction_state(runtime_state);
11797            return Err(error);
11798        }
11799        self.runtime.dispatch_reports(&reports);
11800        self.commit_pending_checkpoint(cache, store).await.map(Some)
11801    }
11802
11803    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
11804    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
11805    /// ephemeral subscriber before polling.
11806    ///
11807    /// # Errors
11808    ///
11809    /// Returns [`ReactiveEngineError`] for the same durability, identity,
11810    /// rollback-proof, replay-witness, polling, ingestion, persistence,
11811    /// mutation-fence, and acknowledgement failures as
11812    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
11813    pub async fn next_ingest_with_resync_checkpointed(
11814        &mut self,
11815        cache: &mut EvmCache,
11816        store: &DurableCheckpointStore,
11817        identity: &DurableCheckpointIdentity,
11818    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11819        if !self.subscriber.capabilities().supports_durable_replay() {
11820            return Err(ReactiveEngineError::SubscriberNotDurable);
11821        }
11822        self.ensure_subscriber_chain(cache)?;
11823        if self.pending_acknowledgement.is_some() {
11824            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11825        }
11826        self.ensure_checkpoint_identity(cache, identity)?;
11827        if self.pending_checkpoint.is_some() {
11828            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11829        }
11830
11831        let batch = self.subscriber.next_batch().await?;
11832        self.ensure_subscriber_chain(cache)?;
11833        let Some(mut batch) = batch else {
11834            return Ok(None);
11835        };
11836        if batch_preconfirmation(&batch)?.is_some() {
11837            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11838        }
11839        self.runtime.discard_preconfirmed_branch(cache);
11840        let delivery_witness = batch
11841            .delivery_token()
11842            .map(|_| durable_delivery_witness(&batch))
11843            .transpose()?;
11844        let delivery_token = batch.take_delivery_token();
11845        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11846        if let (Some(replay_token), Some(committed_token)) = (
11847            delivery_token.as_ref(),
11848            self.last_checkpoint_delivery_token.as_ref(),
11849        ) && replay_token == committed_token
11850        {
11851            let committed_witness = self
11852                .last_checkpoint_delivery_witness
11853                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11854            if delivery_witness != Some(committed_witness) {
11855                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11856            }
11857            self.subscriber
11858                .acknowledge_delivery(replay_token.clone())
11859                .await
11860                .map_err(ReactiveEngineError::Acknowledgement)?;
11861            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11862        }
11863
11864        self.ensure_checkpointable_reorgs(&batch)?;
11865
11866        let incoming_block = latest_canonical_batch_block(&batch);
11867        let cache_state = EvmCacheStateSnapshot::capture(cache);
11868        let runtime_state = self.runtime.checkpoint_state();
11869        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
11870            Ok(report) => report,
11871            Err(error) => {
11872                cache_state.restore(cache);
11873                self.runtime.restore_transaction_state(runtime_state);
11874                return Err(error.into());
11875            }
11876        };
11877        let reports = report.reports.clone();
11878        let stage = CheckpointStage {
11879            incoming_block,
11880            delivery_token,
11881            delivery_witness,
11882            subscriber_checkpoint,
11883            staged_generation: cache.snapshot_generation(),
11884            report,
11885        };
11886        if let Err(error) = self.stage_checkpoint(identity, stage) {
11887            cache_state.restore(cache);
11888            self.runtime.restore_transaction_state(runtime_state);
11889            return Err(error);
11890        }
11891        self.runtime.dispatch_reports(&reports);
11892        self.commit_pending_checkpoint(cache, store).await.map(Some)
11893    }
11894
11895    fn stage_checkpoint(
11896        &mut self,
11897        identity: &DurableCheckpointIdentity,
11898        stage: CheckpointStage<N>,
11899    ) -> Result<(), ReactiveEngineError> {
11900        let CheckpointStage {
11901            incoming_block,
11902            delivery_token,
11903            delivery_witness,
11904            subscriber_checkpoint,
11905            staged_generation,
11906            report,
11907        } = stage;
11908        if delivery_token.is_some() != delivery_witness.is_some() {
11909            return Err(ReactiveEngineError::DeliveryWitness(
11910                "delivery token and witness must be staged together".into(),
11911            ));
11912        }
11913        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
11914        let block = self
11915            .runtime
11916            .last_canonical_block()
11917            .map(|block| DurableCheckpointBlock {
11918                number: block.number,
11919                hash: block.hash,
11920                parent_hash: block.parent_hash,
11921                timestamp: block.timestamp,
11922            })
11923            .or(incoming_block)
11924            .or_else(|| self.last_checkpoint_block.clone())
11925            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
11926        let metadata = DurableCheckpointMetadata {
11927            identity: identity.clone(),
11928            block,
11929            delivery_token: delivery_token
11930                .as_ref()
11931                .or(self.last_checkpoint_delivery_token.as_ref())
11932                .map(|token| token.as_bytes().to_vec()),
11933            delivery_witness: if delivery_token.is_some() {
11934                delivery_witness
11935            } else {
11936                self.last_checkpoint_delivery_witness
11937            },
11938            subscriber_checkpoint: subscriber_checkpoint
11939                .as_ref()
11940                .or(self.last_subscriber_checkpoint.as_ref())
11941                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
11942            runtime_checkpoint: Some(runtime_checkpoint),
11943        };
11944        self.pending_checkpoint = Some(PendingCheckpoint {
11945            metadata,
11946            delivery_token,
11947            report,
11948            saved_to: None,
11949            staged_generation,
11950        });
11951        Ok(())
11952    }
11953
11954    fn ensure_checkpointable_reorgs(
11955        &self,
11956        batch: &ReactiveInputBatch<N>,
11957    ) -> Result<(), ReactiveEngineError> {
11958        let state = CanonicalSequenceState::new(
11959            self.runtime
11960                .journal
11961                .iter()
11962                .map(|entry| entry.block)
11963                .collect(),
11964            self.runtime.coverage_head,
11965            self.runtime.safe_head,
11966            self.runtime.finalized_head,
11967        );
11968        match validate_canonical_sequence_internal(
11969            &state,
11970            batch,
11971            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
11972        ) {
11973            Ok(_) => Ok(()),
11974            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
11975            Err(CanonicalSequenceError::IncompleteRollback {
11976                common_ancestor,
11977                oldest_retained,
11978                ..
11979            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
11980                common_ancestor,
11981                oldest_journaled: oldest_retained,
11982                journal_depth: self.runtime.config.journal_depth,
11983            }),
11984        }
11985    }
11986
11987    async fn stage_or_return_acknowledgement(
11988        &mut self,
11989        delivery_token: Option<SubscriberDeliveryToken>,
11990        report: ReactiveBatchReport<N>,
11991    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11992        let Some(token) = delivery_token else {
11993            return Ok(Some(report));
11994        };
11995        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
11996        self.commit_pending_acknowledgement().await.map(Some)
11997    }
11998
11999    async fn commit_pending_acknowledgement(
12000        &mut self,
12001    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
12002        let token = self
12003            .pending_acknowledgement
12004            .as_ref()
12005            .expect("caller checked pending acknowledgement")
12006            .token
12007            .clone();
12008        self.subscriber
12009            .acknowledge_delivery(token)
12010            .await
12011            .map_err(ReactiveEngineError::Acknowledgement)?;
12012        Ok(self
12013            .pending_acknowledgement
12014            .take()
12015            .expect("pending acknowledgement remains until commit")
12016            .report)
12017    }
12018
12019    async fn commit_pending_checkpoint(
12020        &mut self,
12021        cache: &EvmCache,
12022        store: &DurableCheckpointStore,
12023    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
12024        let pending = self
12025            .pending_checkpoint
12026            .as_mut()
12027            .expect("caller checked pending checkpoint");
12028        let cache_generation = cache.snapshot_generation();
12029        if cache_generation != pending.staged_generation {
12030            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
12031                staged_generation: pending.staged_generation,
12032                current_generation: cache_generation,
12033            });
12034        }
12035        if pending.saved_to.as_deref() != Some(store.path()) {
12036            store
12037                .save_async(cache, pending.metadata.clone())
12038                .await
12039                .map_err(ReactiveEngineError::Checkpoint)?;
12040            pending.saved_to = Some(store.path().to_path_buf());
12041        }
12042        if let Some(token) = pending.delivery_token.clone() {
12043            self.subscriber
12044                .acknowledge_delivery(token)
12045                .await
12046                .map_err(ReactiveEngineError::Acknowledgement)?;
12047        }
12048
12049        let pending = self
12050            .pending_checkpoint
12051            .take()
12052            .expect("pending checkpoint remains until commit");
12053        self.last_checkpoint_block = Some(pending.metadata.block);
12054        self.checkpoint_identity = Some(pending.metadata.identity);
12055        self.last_checkpoint_delivery_token = pending
12056            .metadata
12057            .delivery_token
12058            .map(SubscriberDeliveryToken::new);
12059        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
12060        self.last_subscriber_checkpoint = pending
12061            .metadata
12062            .subscriber_checkpoint
12063            .map(SubscriberCheckpoint::new);
12064        Ok(CheckpointedIngest::Applied(pending.report))
12065    }
12066
12067    fn ensure_checkpoint_identity(
12068        &self,
12069        cache: &EvmCache,
12070        identity: &DurableCheckpointIdentity,
12071    ) -> Result<(), ReactiveEngineError> {
12072        if identity.chain_id != cache.chain_id() {
12073            return Err(ReactiveEngineError::Checkpoint(
12074                DurableCheckpointError::CacheChainMismatch {
12075                    cache_chain_id: cache.chain_id(),
12076                    checkpoint_chain_id: identity.chain_id,
12077                },
12078            ));
12079        }
12080        if let Some(actual) = self.checkpoint_identity.as_ref()
12081            && actual != identity
12082        {
12083            return Err(ReactiveEngineError::Checkpoint(
12084                DurableCheckpointError::IdentityMismatch {
12085                    expected: identity.clone(),
12086                    actual: actual.clone(),
12087                },
12088            ));
12089        }
12090        if let Some(pending) = self.pending_checkpoint.as_ref()
12091            && &pending.metadata.identity != identity
12092        {
12093            return Err(ReactiveEngineError::Checkpoint(
12094                DurableCheckpointError::IdentityMismatch {
12095                    expected: identity.clone(),
12096                    actual: pending.metadata.identity.clone(),
12097                },
12098            ));
12099        }
12100        Ok(())
12101    }
12102}
12103
12104fn latest_canonical_batch_block<N: Network>(
12105    batch: &ReactiveInputBatch<N>,
12106) -> Option<DurableCheckpointBlock> {
12107    let record_block = batch
12108        .records()
12109        .iter()
12110        .enumerate()
12111        .filter(|(index, _)| {
12112            batch
12113                .record_delivery_scope(*index)
12114                .is_some_and(DeliveryScope::advances_canonical_state)
12115        })
12116        .filter_map(|(_, record)| canonical_record_block(record))
12117        .max_by_key(|block| block.number)
12118        .cloned();
12119    let control_block = batch
12120        .chain_controls()
12121        .iter()
12122        .filter_map(|control| match control {
12123            ChainControl::Reorg {
12124                common_ancestor, ..
12125            } => Some(common_ancestor),
12126            ChainControl::Barrier {
12127                block: Some(block), ..
12128            }
12129            | ChainControl::CanonicalProgress(block) => Some(block),
12130            ChainControl::Safe(_)
12131            | ChainControl::Finalized(_)
12132            | ChainControl::Barrier { block: None, .. } => None,
12133        })
12134        .max_by_key(|block| block.number)
12135        .cloned();
12136
12137    record_block
12138        .into_iter()
12139        .chain(control_block)
12140        .max_by_key(|block| block.number)
12141        .map(|block| DurableCheckpointBlock {
12142            number: block.number,
12143            hash: block.hash,
12144            parent_hash: block.parent_hash,
12145            timestamp: block.timestamp,
12146        })
12147}
12148
12149impl<S, N> ReactiveEngine<S, N>
12150where
12151    N: Network,
12152    S: InterestOwnerSubscriber<N>,
12153{
12154    /// Register a handler with both the runtime and subscriber, backfilling its
12155    /// log interests from the runtime's last canonical block.
12156    ///
12157    /// This is the continuity-safe default for mid-lifecycle registration. The
12158    /// subscriber adopts the live desired state first, delivers the new owner's
12159    /// matching records at retained block `C` as owner catch-up, then delivers
12160    /// `C + 1` through activation as global canonical catch-up over the complete
12161    /// handler union. No discovery gap opens, and every effect after `C` enters
12162    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
12163    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
12164    /// pre-ingestion bootstrap. Use
12165    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
12166    /// for an explicit replay of one retained block or
12167    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
12168    /// out of backfill entirely.
12169    ///
12170    /// Subscriber registration commits before runtime routing is installed. If
12171    /// the subscriber operation fails or is cancelled, the runtime remains
12172    /// unchanged.
12173    ///
12174    /// # Errors
12175    ///
12176    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12177    /// registered or the subscriber rejects/does not support the required
12178    /// owner update or coordinated catch-up.
12179    pub async fn register_handler(
12180        &mut self,
12181        handler: Arc<dyn ReactiveHandler<N>>,
12182    ) -> Result<(), ReactiveEngineRegisterError> {
12183        let backfill = self
12184            .runtime
12185            .last_canonical_block()
12186            .filter(|retained| {
12187                self.runtime.journal.iter().any(|entry| {
12188                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
12189                })
12190            })
12191            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
12192            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
12193        self.register_handler_inner(handler, backfill).await
12194    }
12195
12196    /// Register a handler and replay its matching logs at one exact retained
12197    /// canonical block.
12198    ///
12199    /// Owner-only effects are appended to that block's existing rollback
12200    /// journal entry. Consequently this method accepts only a bounded
12201    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
12202    /// anchor all identify the same journaled block. Wider/deeper recovery must
12203    /// use ordinary global canonical ingestion (for example startup catch-up),
12204    /// where every handler sees the records and the runtime advances coverage.
12205    ///
12206    /// If subscriber registration fails or is cancelled, the runtime remains
12207    /// unchanged.
12208    ///
12209    /// # Errors
12210    ///
12211    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12212    /// registered, the requested backfill is not exactly one hash-certified
12213    /// retained journal block, or the subscriber update fails.
12214    pub async fn register_handler_with_backfill(
12215        &mut self,
12216        handler: Arc<dyn ReactiveHandler<N>>,
12217        backfill: SubscriberBackfill,
12218    ) -> Result<(), ReactiveEngineRegisterError> {
12219        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
12220            .await
12221    }
12222
12223    /// Register a handler without any log backfill — only logs delivered after
12224    /// its live subscription starts are routed to it.
12225    ///
12226    /// If subscriber registration fails or is cancelled, the runtime remains
12227    /// unchanged.
12228    ///
12229    /// # Errors
12230    ///
12231    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12232    /// registered or the subscriber cannot commit the owner update.
12233    pub async fn register_handler_live_only(
12234        &mut self,
12235        handler: Arc<dyn ReactiveHandler<N>>,
12236    ) -> Result<(), ReactiveEngineRegisterError> {
12237        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
12238            .await
12239    }
12240
12241    async fn register_handler_inner(
12242        &mut self,
12243        handler: Arc<dyn ReactiveHandler<N>>,
12244        catchup: HandlerRegistrationCatchup,
12245    ) -> Result<(), ReactiveEngineRegisterError> {
12246        let id = handler.id();
12247        if self.runtime.contains_handler(&id) {
12248            return Err(RegisterError::DuplicateHandler(id).into());
12249        }
12250        let interests = handler.interests();
12251
12252        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
12253            let retained_anchor = backfill.retained_anchor().copied();
12254            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
12255                backfill.start_block() == anchor.number
12256                    && backfill.end_block() == Some(anchor.number)
12257                    && self.runtime.journal.iter().any(|entry| {
12258                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
12259                    })
12260            });
12261            if !is_exact_retained_block {
12262                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
12263                    start_block: backfill.start_block(),
12264                    end_block: backfill.end_block(),
12265                    retained_anchor,
12266                });
12267            }
12268        }
12269
12270        let subscribed = match catchup {
12271            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
12272                self.subscriber
12273                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
12274                    .await
12275            }
12276            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
12277                self.subscriber
12278                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
12279                    .await
12280            }
12281            HandlerRegistrationCatchup::LiveOnly => {
12282                self.subscriber
12283                    .add_interest_owner(id.clone(), &interests)
12284                    .await
12285            }
12286        };
12287        if let Err(error) = subscribed {
12288            return Err(error.into());
12289        }
12290
12291        // `&mut self` excludes concurrent registry mutation between the
12292        // duplicate preflight and this commit. Registration is deliberately
12293        // subscriber-first: cancelling the awaited operation cannot leave a
12294        // runtime handler active without committed subscriber interests.
12295        self.runtime
12296            .registry
12297            .insert_handler_prepared(id, handler, interests);
12298        Ok(())
12299    }
12300
12301    /// Register every handler currently in the runtime registry as a subscriber
12302    /// interest owner.
12303    ///
12304    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
12305    /// before ingestion starts, or for reattaching an already-aligned durable
12306    /// subscriber whose exact owner state was restored independently. Each
12307    /// handler becomes its own owner through one exact bulk replacement;
12308    /// crash-stale owners and unowned/base interests are removed.
12309    ///
12310    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
12311    /// potentially stale subscriber after the runtime has processed canonical
12312    /// state: use
12313    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
12314    /// which exact-replaces the owner set and closes continuity from the
12315    /// restored runtime position.
12316    ///
12317    /// The complete exact set commits through one subscriber operation; an
12318    /// error or cancellation leaves the previously committed topology
12319    /// authoritative.
12320    ///
12321    /// # Errors
12322    ///
12323    /// Returns [`SubscriberError`] when the subscriber cannot atomically
12324    /// replace the complete owner topology.
12325    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
12326        let owners = self
12327            .runtime
12328            .handler_ids()
12329            .into_iter()
12330            .map(|id| {
12331                let interests = self
12332                    .runtime
12333                    .handler_interests(&id)
12334                    .map(<[ReactiveInterest<N>]>::to_vec)
12335                    .unwrap_or_default();
12336                (id, interests)
12337            })
12338            .collect();
12339        self.subscriber.replace_interest_owners(owners).await
12340    }
12341
12342    /// Rebuild subscriber owner state from a runtime that already embodies a
12343    /// canonical checkpoint.
12344    ///
12345    /// The runtime registry is authoritative: the subscriber must atomically
12346    /// replace its complete owner set, removing crash-stale owners as well as
12347    /// adding the current ones. Log catch-up is routed globally through normal
12348    /// canonical ingestion and begins strictly at `C + 1`, where
12349    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
12350    /// cache already contains every effect through `C`. The exact number/hash
12351    /// identity of `C` remains attached as a retained baseline and must be
12352    /// validated by the subscriber before it exposes post-baseline records.
12353    /// Global routing is essential: startup catch-up effects enter the ordinary
12354    /// canonical journal and can be rolled back if the certified branch later
12355    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
12356    /// handler addition.
12357    ///
12358    /// A runtime without a canonical position must use
12359    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
12360    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
12361    /// The replacement is one subscriber commit boundary: errors and
12362    /// cancellation leave the previous topology authoritative.
12363    ///
12364    /// # Errors
12365    ///
12366    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
12367    /// exists or no exclusive successor can be represented, and otherwise
12368    /// propagates subscriber validation, transport, or atomic-commit failures.
12369    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
12370        let baseline =
12371            self.runtime
12372                .last_canonical_block()
12373                .ok_or(SubscriberError::InvalidConfig(
12374                    "cannot continuity-sync handlers before a canonical runtime position exists",
12375                ))?;
12376        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
12377        let owners = self
12378            .runtime
12379            .handler_ids()
12380            .into_iter()
12381            .map(|id| {
12382                let interests = self
12383                    .runtime
12384                    .handler_interests(&id)
12385                    .map(<[ReactiveInterest<N>]>::to_vec)
12386                    .unwrap_or_default();
12387                (id, interests)
12388            })
12389            .collect();
12390        self.subscriber
12391            .replace_interest_owners_with_global_backfill(owners, backfill)
12392            .await
12393    }
12394
12395    /// Unregister a handler from both the subscriber and runtime.
12396    ///
12397    /// Subscriber interests are removed first so no new live records are routed
12398    /// to a handler after it has left the runtime registry. Returns the removed
12399    /// handler when the id was registered. If subscriber removal fails or is
12400    /// cancelled, runtime routing remains installed.
12401    ///
12402    /// This is the routing/transport half of dropping an adapter. State the
12403    /// handler accumulated is deliberately left in place; the complete teardown
12404    /// for a pool or adapter that will not return is:
12405    ///
12406    /// ```text
12407    /// engine.unregister_handler(&id).await?;
12408    /// for request_id in handler_request_ids {
12409    ///     // Drop only this handler generation's queued repair work.
12410    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
12411    /// }
12412    /// for address in exclusively_owned_addresses {
12413    ///     // Shared accounts require caller-side owner reference counting.
12414    ///     engine.runtime_mut().untrack_account(address);
12415    /// }
12416    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
12417    /// ```
12418    ///
12419    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
12420    /// runtime-global and are never touched by handler removal.
12421    ///
12422    /// # Errors
12423    ///
12424    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
12425    /// removal. In that case runtime routing remains installed.
12426    pub async fn unregister_handler(
12427        &mut self,
12428        id: &HandlerId,
12429    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
12430        self.subscriber.remove_interest_owner(id).await?;
12431        Ok(self.runtime.unregister_handler(id))
12432    }
12433}
12434
12435type FlashblockReconnectFuture<N> = Pin<
12436    Box<
12437        dyn Future<
12438                Output = (
12439                    SubscriberStreamSource,
12440                    Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
12441                ),
12442            > + Send,
12443    >,
12444>;
12445
12446/// Alloy-backed event subscriber.
12447///
12448/// The default transport slice drives Alloy pubsub subscriptions for logs,
12449/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
12450/// transport remains available behind the opt-in `reactive-polling` feature.
12451/// Pubsub streams reconnect automatically after termination, and log
12452/// subscriptions are backfilled from the last seen block. Owner-scoped log
12453/// additions can request backfill from an explicit block anchor. Full pending
12454/// transaction hydration and full block bodies remain explicit follow-up work.
12455///
12456/// Historical log fetching is deliberately a bounded live-subscriber aid, not
12457/// a high-volume indexer: each filter/window is issued as one complete-range
12458/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
12459/// an oversized decoded response, but the subscriber does not adaptively split
12460/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
12461/// registration and reconnect windows modest; use an indexing source such as
12462/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
12463///
12464/// With no registered interests, [`EventSubscriber::next_batch`] returns
12465/// `Ok(None)`.
12466pub struct AlloySubscriber<P, N: Network = Ethereum> {
12467    provider: P,
12468    /// Stable identity of an application-managed standardized Flashblock
12469    /// update source. The application owns its transport and lifecycle.
12470    #[cfg(feature = "raw-flashblocks-json")]
12471    external_flashblocks_provider: Option<ProviderRef>,
12472    /// Receiving half of the optional bounded application-to-subscriber queue.
12473    #[cfg(feature = "raw-flashblocks-json")]
12474    external_flashblock_updates:
12475        Option<tokio::sync::mpsc::Receiver<raw_json_flashblocks::QueuedFlashblockUpdate>>,
12476    /// Whether an external update queue was opened for this subscriber.
12477    #[cfg(feature = "raw-flashblocks-json")]
12478    external_flashblock_update_channel_opened: bool,
12479    /// Highest external generation rejected by subscriber-level validation.
12480    #[cfg(feature = "raw-flashblocks-json")]
12481    rejected_external_flashblock_generation: Option<u64>,
12482    /// Last accepted externally standardized snapshot, retained so callers
12483    /// cannot bypass indexed-payload continuity enforced by the raw adapter.
12484    #[cfg(feature = "raw-flashblocks-json")]
12485    last_external_flashblock_snapshot: Option<FlashblockSnapshot>,
12486    /// Optional request/response half of the same configured provider lease.
12487    /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC
12488    /// does not expose the provider's pending-state surface.
12489    flashblocks_state_provider: Option<P>,
12490    /// Stable identity for the provider session used by Flashblocks and every
12491    /// follow-up pending-state read.
12492    provider_ref: Option<ProviderRef>,
12493    /// Optional provider dedicated to canonical log-context verification.
12494    /// Keeping this separate prevents a high-volume pubsub connection from
12495    /// starving its own verification requests behind log notifications.
12496    log_verification_provider: Option<P>,
12497    /// Provider chain identity, resolved once before any record can escape.
12498    chain_id: Option<u64>,
12499    mode: SubscriberMode,
12500    config: SubscriberConfig,
12501    base_interests: Vec<ReactiveInterest<N>>,
12502    owned_interests: Vec<OwnedSubscriberInterests<N>>,
12503    next_owner_epoch: u64,
12504    interests: Vec<ReactiveInterest<N>>,
12505    /// Stable source id per distinct provider-facing log filter. Ids key
12506    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
12507    /// their anchors pruned) when no planned stream references the filter, so
12508    /// long-lived owner churn cannot grow this map unboundedly.
12509    log_source_ids: HashMap<Filter, usize>,
12510    next_log_source_id: usize,
12511    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
12512    /// Successfully connected sources whose subscribe-then-backfill step has
12513    /// not committed yet. Installation happens before the backfill await, so a
12514    /// cancelled reconcile keeps the live stream and retries only the missing
12515    /// historical window.
12516    pending_source_backfills: VecDeque<SubscriberStreamSource>,
12517    /// Set when interest bookkeeping changed since the last successful stream
12518    /// reconcile, so steady-state polling skips the desired-vs-live diff.
12519    sources_dirty: bool,
12520    /// Conservative generation of desired/live stream topology. Successful
12521    /// owner progress is activatable only against the same clean revision.
12522    stream_revision: u64,
12523    state: AlloySubscriberState<N>,
12524    pending_records: VecDeque<SubscriberInputRecord<N>>,
12525    pending_chain_controls: VecDeque<ChainControl>,
12526    /// Owner copies of live records consumed during an in-flight reconcile.
12527    /// These remain hidden from subscriber output until the owning reconcile
12528    /// commits and survive cancellation so subscribe-first adoption cannot
12529    /// lose an event at an await boundary.
12530    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
12531    /// Sticky fail-closed capacity error. Once an event could not be retained,
12532    /// only a full replacement registration can establish a new baseline.
12533    resource_error: Option<String>,
12534    last_seen_log_blocks: HashMap<usize, u64>,
12535    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
12536    verified_log_block_order: VecDeque<(u64, B256)>,
12537    recent_input_refs: VecDeque<InputRef>,
12538    recent_input_ref_set: HashSet<InputRef>,
12539    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
12540    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
12541    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
12542    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
12543    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
12544    base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
12545    unmatched_pending_logs: VecDeque<(usize, Log, FlashblockIngressTiming)>,
12546    latest_preconfirmation: Option<FlashblockRef>,
12547    preconfirmed_seen_logs: HashSet<(B256, u64)>,
12548    /// OP transaction receipts already proven for the active cumulative
12549    /// payload. This avoids re-querying non-matching transactions while still
12550    /// retrying receipts that were temporarily unavailable.
12551    preconfirmed_receipted_transactions: HashSet<B256>,
12552    /// OP receipt hashes that returned `null` at least once for the active
12553    /// payload. Never-attempted hashes are scheduled ahead of this retry set so
12554    /// a lagging provider cache cannot let a few transactions monopolize the
12555    /// bounded request budget.
12556    preconfirmed_unavailable_receipts: HashSet<B256>,
12557    last_certified_canonical_head: Option<BlockRef>,
12558    pending_preconfirmation_invalidation: bool,
12559    pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
12560    pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
12561    flashblocks_rpc_metrics: FlashblocksRpcMetrics,
12562    consecutive_flashblock_poll_failures: usize,
12563    flashblock_rpc_request_times: VecDeque<Instant>,
12564    _network: PhantomData<N>,
12565}
12566
12567struct OwnedSubscriberInterests<N: Network = Ethereum> {
12568    owner: HandlerId,
12569    interests: Vec<ReactiveInterest<N>>,
12570    epoch: Option<SubscriberOwnerEpoch>,
12571    state: SubscriberOwnerState,
12572    baseline: Option<BlockRef>,
12573    progress: Option<SubscriberOwnerProgress>,
12574    progress_stream_revision: Option<u64>,
12575}
12576
12577#[derive(Clone)]
12578struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
12579    epoch: SubscriberOwnerEpoch,
12580    interests: Vec<ReactiveInterest<N>>,
12581    retained: BlockRef,
12582    from_block: u64,
12583}
12584
12585struct SubscriberOwnerCatchup {
12586    logs: Vec<Log>,
12587    certified: BlockRef,
12588}
12589
12590#[derive(Clone, Copy)]
12591struct SubscriberOwnerCatchupOptions {
12592    target_preverified: bool,
12593    max_logs: usize,
12594    max_log_bytes: usize,
12595    max_requests_in_flight: usize,
12596}
12597
12598struct SubscriberOwnerReconcileFilter {
12599    filter: Filter,
12600    from_block: u64,
12601}
12602
12603struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
12604    record: ReactiveInputRecord<N>,
12605    owners: Vec<SubscriberOwnerEpoch>,
12606}
12607
12608const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
12609
12610struct QueuedSubscriberBackfill {
12611    /// `None` means global canonical catch-up; `Some` is compatibility
12612    /// owner-only catch-up for true mid-lifecycle additions.
12613    owner: Option<HandlerId>,
12614    epoch: Option<SubscriberOwnerEpoch>,
12615    /// Complete logical filter set for one certified, globally ordered window.
12616    filters: Vec<Filter>,
12617    backfill: SubscriberBackfill,
12618}
12619
12620/// Best-effort installation of rustls' `ring` crypto provider as the process
12621/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
12622/// "no process-level CryptoProvider available". Runs at most once and ignores the
12623/// error if a default provider is already installed (the host app may have set
12624/// its own).
12625#[cfg(feature = "reactive-ws")]
12626fn ensure_ring_crypto_provider() {
12627    use std::sync::Once;
12628    static INSTALL: Once = Once::new();
12629    INSTALL.call_once(|| {
12630        let _ = rustls::crypto::ring::default_provider().install_default();
12631    });
12632}
12633
12634impl<P, N: Network> AlloySubscriber<P, N> {
12635    /// Create a new Alloy subscriber.
12636    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
12637        #[cfg(feature = "reactive-ws")]
12638        ensure_ring_crypto_provider();
12639        Self {
12640            provider,
12641            #[cfg(feature = "raw-flashblocks-json")]
12642            external_flashblocks_provider: None,
12643            #[cfg(feature = "raw-flashblocks-json")]
12644            external_flashblock_updates: None,
12645            #[cfg(feature = "raw-flashblocks-json")]
12646            external_flashblock_update_channel_opened: false,
12647            #[cfg(feature = "raw-flashblocks-json")]
12648            rejected_external_flashblock_generation: None,
12649            #[cfg(feature = "raw-flashblocks-json")]
12650            last_external_flashblock_snapshot: None,
12651            flashblocks_state_provider: None,
12652            provider_ref: None,
12653            log_verification_provider: None,
12654            chain_id: None,
12655            mode,
12656            config,
12657            base_interests: Vec::new(),
12658            owned_interests: Vec::new(),
12659            next_owner_epoch: 0,
12660            interests: Vec::new(),
12661            log_source_ids: HashMap::new(),
12662            next_log_source_id: 0,
12663            pending_backfills: VecDeque::new(),
12664            pending_source_backfills: VecDeque::new(),
12665            sources_dirty: true,
12666            stream_revision: 0,
12667            state: AlloySubscriberState::Uninitialized,
12668            pending_records: VecDeque::new(),
12669            pending_chain_controls: VecDeque::new(),
12670            pending_reconcile_owner_records: VecDeque::new(),
12671            resource_error: None,
12672            last_seen_log_blocks: HashMap::new(),
12673            verified_log_blocks: HashMap::new(),
12674            verified_log_block_order: VecDeque::new(),
12675            recent_input_refs: VecDeque::new(),
12676            recent_input_ref_set: HashSet::new(),
12677            recent_owner_input_refs: HashMap::new(),
12678            recent_owner_input_ref_sets: HashMap::new(),
12679            recent_compat_owner_input_refs: HashMap::new(),
12680            recent_compat_owner_input_ref_sets: HashMap::new(),
12681            base_flashblock_header: None,
12682            base_flashblock_transactions: None,
12683            unmatched_pending_logs: VecDeque::new(),
12684            latest_preconfirmation: None,
12685            preconfirmed_seen_logs: HashSet::new(),
12686            preconfirmed_receipted_transactions: HashSet::new(),
12687            preconfirmed_unavailable_receipts: HashSet::new(),
12688            last_certified_canonical_head: None,
12689            pending_preconfirmation_invalidation: false,
12690            pending_flashblock_reconnects: FuturesUnordered::new(),
12691            pending_flashblock_reconnect_sources: Vec::new(),
12692            flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
12693            consecutive_flashblock_poll_failures: 0,
12694            flashblock_rpc_request_times: VecDeque::new(),
12695            _network: PhantomData,
12696        }
12697    }
12698
12699    /// Borrow the provider.
12700    pub fn provider(&self) -> &P {
12701        &self.provider
12702    }
12703
12704    /// Bind this subscriber to the concrete provider lease that supplies
12705    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
12706    /// with the single `flashblocks = true` flag.
12707    #[must_use]
12708    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
12709        self.provider_ref = Some(provider);
12710        self
12711    }
12712
12713    /// Select application-managed standardized Flashblock updates before
12714    /// subscriber registration begins.
12715    ///
12716    /// This suppresses the subscriber's chain-specific native or pending-state
12717    /// Flashblocks source. Canonical logs and block headers continue through the
12718    /// configured subscriber transport. The application owns the raw socket,
12719    /// control frames, bounded queue, timeout, retry, backoff, and provider
12720    /// rotation, and passes decoded updates to
12721    /// [`Self::ingest_flashblock_update`].
12722    ///
12723    /// Call [`Self::ingest_flashblock_update`] directly while retaining mutable
12724    /// subscriber ownership, or open a bounded handoff with
12725    /// [`Self::open_external_flashblock_update_channel`] before moving the
12726    /// subscriber into another runtime owner.
12727    ///
12728    /// This is deliberately a fallible construction-time configuration method,
12729    /// not a live reconfiguration API. Replacing a source after canonical or
12730    /// speculative processing begins requires a new subscriber so existing
12731    /// streams and overlays cannot survive under ambiguous provider ownership.
12732    ///
12733    /// # Errors
12734    ///
12735    /// Returns [`SubscriberError::InvalidConfig`] when an external source was
12736    /// already selected or subscriber registration, stream installation, or
12737    /// event processing has begun.
12738    #[cfg(feature = "raw-flashblocks-json")]
12739    pub fn configure_external_flashblock_updates(
12740        &mut self,
12741        provider: ProviderRef,
12742    ) -> Result<(), SubscriberError> {
12743        if self.external_flashblocks_provider.is_some() {
12744            return Err(SubscriberError::InvalidConfig(
12745                "external Flashblock updates were already configured",
12746            ));
12747        }
12748        if self.external_flashblock_update_channel_opened
12749            || self.external_flashblock_updates.is_some()
12750            || self.chain_id.is_some()
12751            || !self.base_interests.is_empty()
12752            || !self.owned_interests.is_empty()
12753            || !self.interests.is_empty()
12754            || !self.pending_records.is_empty()
12755            || !self.pending_chain_controls.is_empty()
12756            || !self.pending_backfills.is_empty()
12757            || !matches!(self.state, AlloySubscriberState::Uninitialized)
12758        {
12759            return Err(SubscriberError::InvalidConfig(
12760                "external Flashblock updates must be configured before subscriber registration",
12761            ));
12762        }
12763        self.external_flashblocks_provider = Some(provider);
12764        Ok(())
12765    }
12766
12767    /// Open one bounded standardized-update queue and return its application handle.
12768    ///
12769    /// The queue is useful when the subscriber will be moved into a runtime
12770    /// driver: the application retains the cloneable sender while the subscriber
12771    /// continues to own all validation, speculative deduplication, and canonical
12772    /// reconciliation. Opening a queue does not create a socket or background
12773    /// task, and does not implement retry or backoff. Awaited sends complete
12774    /// only after subscriber validation; non-blocking sends return an explicit
12775    /// acknowledgement receipt.
12776    ///
12777    /// # Errors
12778    ///
12779    /// Returns [`SubscriberError::InvalidConfig`] if external updates were not
12780    /// selected first, `capacity` is zero, or a queue was already opened.
12781    #[cfg(feature = "raw-flashblocks-json")]
12782    pub fn open_external_flashblock_update_channel(
12783        &mut self,
12784        capacity: usize,
12785    ) -> Result<FlashblockUpdateSender, SubscriberError> {
12786        if capacity == 0 {
12787            return Err(SubscriberError::InvalidConfig(
12788                "external Flashblock update channel capacity must be greater than zero",
12789            ));
12790        }
12791        let provider = self.external_flashblocks_provider.clone().ok_or(
12792            SubscriberError::InvalidConfig(
12793                "external Flashblock update channel requires configure_external_flashblock_updates",
12794            ),
12795        )?;
12796        if self.external_flashblock_update_channel_opened {
12797            return Err(SubscriberError::InvalidConfig(
12798                "external Flashblock update channel was already opened",
12799            ));
12800        }
12801        let (sender, receiver) =
12802            raw_json_flashblocks::flashblock_update_channel(provider, capacity);
12803        self.external_flashblock_updates = Some(receiver);
12804        self.external_flashblock_update_channel_opened = true;
12805        self.sources_dirty = true;
12806        Ok(sender)
12807    }
12808
12809    fn uses_external_flashblock_updates(&self) -> bool {
12810        #[cfg(feature = "raw-flashblocks-json")]
12811        {
12812            self.external_flashblocks_provider.is_some()
12813        }
12814        #[cfg(not(feature = "raw-flashblocks-json"))]
12815        {
12816            false
12817        }
12818    }
12819
12820    /// Pair the subscriber's event transport with the request/response
12821    /// transport for the same configured provider ID and generation.
12822    ///
12823    /// Optimism pending block/log sampling uses this provider. Preflight reads
12824    /// its chain ID and rejects a mismatch before pending data can be emitted.
12825    /// Use type-erased Alloy providers when the WebSocket and HTTP transports
12826    /// have different concrete Rust types.
12827    #[must_use]
12828    pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
12829        self.flashblocks_state_provider = Some(provider);
12830        self
12831    }
12832
12833    /// Use a separate provider for canonical log-context verification.
12834    ///
12835    /// This is recommended with
12836    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
12837    /// deployments. The provider must target the same chain; every fetched
12838    /// block is still checked against the log's number, hash, and timestamp.
12839    #[must_use]
12840    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
12841        self.log_verification_provider = Some(provider);
12842        self
12843    }
12844
12845    /// Subscriber mode.
12846    pub fn mode(&self) -> SubscriberMode {
12847        self.mode
12848    }
12849
12850    /// Subscriber config.
12851    pub fn config(&self) -> &SubscriberConfig {
12852        &self.config
12853    }
12854
12855    /// Request/response traffic issued for Flashblocks qualification and
12856    /// pending-state sampling since the last full interest reset.
12857    pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
12858        self.flashblocks_rpc_metrics
12859    }
12860
12861    /// Registered interests across base and owner-scoped registrations.
12862    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
12863        &self.interests
12864    }
12865
12866    /// Stage a fresh, epoch-scoped interest owner without making its inputs
12867    /// canonically routable yet.
12868    ///
12869    /// The returned token is required by every later lifecycle operation. A
12870    /// staged owner participates in provider subscription planning immediately,
12871    /// while its matching input remains owner-scoped until
12872    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
12873    /// Post-block owners require hash-certified
12874    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
12875    /// the current clean stream revision before activation.
12876    ///
12877    /// # Errors
12878    ///
12879    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12880    /// duplicate owners, unsupported post-block interests, unsupported
12881    /// transport interests, block-number overflow, or epoch exhaustion.
12882    pub fn stage_interest_owner(
12883        &mut self,
12884        owner: HandlerId,
12885        interests: &[ReactiveInterest<N>],
12886        start: SubscriberOwnerStart,
12887    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12888        validate_subscriber_config(&self.config)?;
12889        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12890            && interests
12891                .iter()
12892                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12893        {
12894            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12895        }
12896        if self
12897            .owned_interests
12898            .iter()
12899            .any(|entry| entry.owner == owner)
12900        {
12901            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12902        }
12903
12904        let mut next_owned = self.clone_owned_interests();
12905        next_owned.push(OwnedSubscriberInterests {
12906            owner: owner.clone(),
12907            interests: interests.to_vec(),
12908            epoch: None,
12909            state: SubscriberOwnerState::Staged,
12910            baseline: None,
12911            progress: None,
12912            progress_stream_revision: None,
12913        });
12914        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12915        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12916
12917        let baseline = match start {
12918            SubscriberOwnerStart::Live => None,
12919            SubscriberOwnerStart::PostBlock(block) => {
12920                block
12921                    .number
12922                    .checked_add(1)
12923                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12924                Some(block)
12925            }
12926        };
12927        let sequence = self
12928            .next_owner_epoch
12929            .checked_add(1)
12930            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12931        let epoch = SubscriberOwnerEpoch {
12932            owner: owner.clone(),
12933            sequence,
12934        };
12935
12936        self.next_owner_epoch = sequence;
12937        let entry = next_owned
12938            .last_mut()
12939            .expect("staged owner was appended during preflight");
12940        entry.epoch = Some(epoch.clone());
12941        entry.baseline = baseline;
12942        self.owned_interests = next_owned;
12943        self.interests = next_registered;
12944        self.sources_dirty = true;
12945
12946        Ok(epoch)
12947    }
12948
12949    /// Stage replacement interests for one currently active logical owner.
12950    ///
12951    /// The active epoch remains canonical while the replacement reconciles.
12952    /// Commit both epochs atomically with
12953    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
12954    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
12955    ///
12956    /// # Errors
12957    ///
12958    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12959    /// missing/non-unique active owner state, unsupported post-block interests,
12960    /// unsupported transport interests, block-number overflow, or epoch
12961    /// exhaustion.
12962    pub fn stage_interest_owner_replacement(
12963        &mut self,
12964        owner: HandlerId,
12965        interests: &[ReactiveInterest<N>],
12966        start: SubscriberOwnerStart,
12967    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12968        validate_subscriber_config(&self.config)?;
12969        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12970            && interests
12971                .iter()
12972                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12973        {
12974            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12975        }
12976        let active_count = self
12977            .owned_interests
12978            .iter()
12979            .filter(|entry| {
12980                entry.owner == owner
12981                    && entry.state == SubscriberOwnerState::Active
12982                    && entry.epoch.is_some()
12983            })
12984            .count();
12985        if active_count != 1
12986            || self
12987                .owned_interests
12988                .iter()
12989                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
12990        {
12991            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12992        }
12993
12994        let mut next_owned = self.clone_owned_interests();
12995        next_owned.push(OwnedSubscriberInterests {
12996            owner: owner.clone(),
12997            interests: interests.to_vec(),
12998            epoch: None,
12999            state: SubscriberOwnerState::Staged,
13000            baseline: None,
13001            progress: None,
13002            progress_stream_revision: None,
13003        });
13004        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13005        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13006
13007        let baseline = match start {
13008            SubscriberOwnerStart::Live => None,
13009            SubscriberOwnerStart::PostBlock(block) => {
13010                block
13011                    .number
13012                    .checked_add(1)
13013                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
13014                Some(block)
13015            }
13016        };
13017        let sequence = self
13018            .next_owner_epoch
13019            .checked_add(1)
13020            .ok_or(SubscriberOwnerError::EpochExhausted)?;
13021        let epoch = SubscriberOwnerEpoch {
13022            owner: owner.clone(),
13023            sequence,
13024        };
13025
13026        self.next_owner_epoch = sequence;
13027        let entry = next_owned
13028            .last_mut()
13029            .expect("staged replacement owner was appended during preflight");
13030        entry.epoch = Some(epoch.clone());
13031        entry.baseline = baseline;
13032        self.owned_interests = next_owned;
13033        self.interests = next_registered;
13034        self.sources_dirty = true;
13035        Ok(epoch)
13036    }
13037
13038    /// Current transaction state for an exact owner epoch.
13039    pub fn interest_owner_state(
13040        &self,
13041        epoch: &SubscriberOwnerEpoch,
13042    ) -> Option<SubscriberOwnerState> {
13043        self.owned_interests
13044            .iter()
13045            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13046            .map(|entry| entry.state)
13047    }
13048
13049    /// Latest hash-certified reconcile progress for an exact owner epoch.
13050    pub fn interest_owner_progress(
13051        &self,
13052        epoch: &SubscriberOwnerEpoch,
13053    ) -> Option<&SubscriberOwnerProgress> {
13054        self.owned_interests
13055            .iter()
13056            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13057            .and_then(|entry| entry.progress.as_ref())
13058    }
13059
13060    /// Make a staged owner canonical after its actor-side installation commits.
13061    ///
13062    /// Returns `false` for stale tokens and owners not currently staged.
13063    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13064        let stream_revision = self.stream_revision;
13065        let sources_dirty = self.sources_dirty;
13066        let Some(entry) = self
13067            .owned_interests
13068            .iter_mut()
13069            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13070        else {
13071            return false;
13072        };
13073        if entry.state != SubscriberOwnerState::Staged
13074            || (entry.baseline.is_some()
13075                && (entry.progress.is_none()
13076                    || entry.progress_stream_revision != Some(stream_revision)
13077                    || sources_dirty))
13078        {
13079            return false;
13080        }
13081        entry.state = SubscriberOwnerState::Active;
13082        true
13083    }
13084
13085    /// Atomically replace one active owner epoch with one reconciled staged epoch.
13086    pub fn commit_interest_owner_replacement(
13087        &mut self,
13088        active: &SubscriberOwnerEpoch,
13089        replacement: &SubscriberOwnerEpoch,
13090    ) -> bool {
13091        let Some(active_index) = self
13092            .owned_interests
13093            .iter()
13094            .position(|entry| entry.epoch.as_ref() == Some(active))
13095        else {
13096            return false;
13097        };
13098        let Some(replacement_index) = self
13099            .owned_interests
13100            .iter()
13101            .position(|entry| entry.epoch.as_ref() == Some(replacement))
13102        else {
13103            return false;
13104        };
13105        if active_index == replacement_index
13106            || active.owner() != replacement.owner()
13107            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
13108            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
13109            || (self.owned_interests[replacement_index].baseline.is_some()
13110                && (self.owned_interests[replacement_index].progress.is_none()
13111                    || self.owned_interests[replacement_index].progress_stream_revision
13112                        != Some(self.stream_revision)
13113                    || self.sources_dirty))
13114        {
13115            return false;
13116        }
13117
13118        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
13119        self.owned_interests.remove(active_index);
13120        self.purge_owner_epoch(active);
13121        self.rebuild_registered_interests();
13122        self.retire_unreferenced_filters();
13123        self.sources_dirty = true;
13124        true
13125    }
13126
13127    /// Prepare an exact active owner for removal without changing desired
13128    /// interests, streams, anchors, or queued canonical input.
13129    ///
13130    /// The caller establishes its delivery fence after this transition. Use
13131    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
13132    /// on actor-side failure, or
13133    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
13134    /// once canonical routing has been removed.
13135    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13136        let Some(entry) = self
13137            .owned_interests
13138            .iter_mut()
13139            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13140        else {
13141            return false;
13142        };
13143        if entry.state != SubscriberOwnerState::Active {
13144            return false;
13145        }
13146        entry.state = SubscriberOwnerState::Removing;
13147        true
13148    }
13149
13150    /// Finalize a previously prepared exact owner removal.
13151    ///
13152    /// Returns the removed interests, or `None` for stale tokens and owners not
13153    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
13154    /// is therefore idempotent.
13155    pub fn finalize_interest_owner_removal(
13156        &mut self,
13157        epoch: &SubscriberOwnerEpoch,
13158    ) -> Option<Vec<ReactiveInterest<N>>> {
13159        let index = self.owned_interests.iter().position(|entry| {
13160            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
13161        })?;
13162        let removed = self.owned_interests.remove(index).interests;
13163        self.purge_owner_epoch(epoch);
13164        self.rebuild_registered_interests();
13165        self.retire_unreferenced_filters();
13166        self.sources_dirty = true;
13167        Some(removed)
13168    }
13169
13170    /// Abort an epoch-scoped owner lifecycle operation.
13171    ///
13172    /// A staged owner is removed completely. A prepared removal is restored to
13173    /// active. Active and unknown epochs are unchanged. Repeating the same
13174    /// abort is therefore safe and returns `false` after the first effect.
13175    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13176        let Some(index) = self
13177            .owned_interests
13178            .iter()
13179            .position(|entry| entry.epoch.as_ref() == Some(epoch))
13180        else {
13181            return false;
13182        };
13183        match self.owned_interests[index].state {
13184            SubscriberOwnerState::Staged => {
13185                self.owned_interests.remove(index);
13186                self.purge_owner_epoch(epoch);
13187                self.rebuild_registered_interests();
13188                self.retire_unreferenced_filters();
13189                self.sources_dirty = true;
13190                true
13191            }
13192            SubscriberOwnerState::Removing => {
13193                self.owned_interests[index].state = SubscriberOwnerState::Active;
13194                true
13195            }
13196            SubscriberOwnerState::Active => false,
13197        }
13198    }
13199
13200    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
13201        self.pending_backfills
13202            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
13203        self.pending_records
13204            .retain_mut(|pending| match &mut pending.scope {
13205                SubscriberInputScope::Canonical { owners }
13206                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
13207                    owners.retain(|owner| owner != epoch);
13208                    true
13209                }
13210                SubscriberInputScope::OwnerOnly { owners } => {
13211                    owners.retain(|owner| owner != epoch);
13212                    !owners.is_empty()
13213                }
13214                SubscriberInputScope::OwnerOnlyHandlers { .. }
13215                | SubscriberInputScope::Preconfirmed => true,
13216            });
13217        self.pending_reconcile_owner_records.retain_mut(|pending| {
13218            pending.owners.retain(|owner| owner != epoch);
13219            !pending.owners.is_empty()
13220        });
13221        self.recent_owner_input_refs.remove(epoch);
13222        self.recent_owner_input_ref_sets.remove(epoch);
13223    }
13224
13225    /// Atomically add or replace several owners while preserving unrelated ones.
13226    ///
13227    /// # Errors
13228    ///
13229    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13230    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13231    /// exhaustion. No owner state changes on error.
13232    pub fn upsert_interest_owners(
13233        &mut self,
13234        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13235    ) -> Result<(), SubscriberError> {
13236        self.upsert_interest_owners_inner(owners, None)
13237    }
13238
13239    /// Atomically add or replace several owners and queue one common backfill
13240    /// policy for every log interest while preserving unrelated owners.
13241    ///
13242    /// # Errors
13243    ///
13244    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13245    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13246    /// exhaustion. No owner or backfill state changes on error.
13247    pub fn upsert_interest_owners_with_backfill(
13248        &mut self,
13249        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13250        backfill: SubscriberBackfill,
13251    ) -> Result<(), SubscriberError> {
13252        self.upsert_interest_owners_inner(owners, Some(backfill))
13253    }
13254
13255    fn upsert_interest_owners_inner(
13256        &mut self,
13257        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13258        explicit_backfill: Option<SubscriberBackfill>,
13259    ) -> Result<(), SubscriberError> {
13260        validate_subscriber_config(&self.config)?;
13261        let mut seen = HashSet::with_capacity(owners.len());
13262        let mut next_owned = self.clone_owned_interests();
13263        for (owner, interests) in &owners {
13264            if !seen.insert(owner.clone()) {
13265                return Err(SubscriberError::InvalidConfig(
13266                    "bulk owner upsert contains a duplicate owner",
13267                ));
13268            }
13269            if self
13270                .owned_interests
13271                .iter()
13272                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
13273            {
13274                return Err(SubscriberError::InvalidConfig(
13275                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13276                ));
13277            }
13278            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
13279                entry.interests = interests.clone();
13280                entry.state = SubscriberOwnerState::Active;
13281                entry.baseline = None;
13282                entry.progress = None;
13283                entry.progress_stream_revision = None;
13284            } else {
13285                next_owned.push(OwnedSubscriberInterests {
13286                    owner: owner.clone(),
13287                    interests: interests.clone(),
13288                    epoch: None,
13289                    state: SubscriberOwnerState::Active,
13290                    baseline: None,
13291                    progress: None,
13292                    progress_stream_revision: None,
13293                });
13294            }
13295        }
13296        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13297        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13298
13299        // Build every owner's replacement queue before the first mutation.
13300        // Besides keeping capacity failure atomic, this preserves continuity
13301        // for changed filter shapes when the caller did not provide a common
13302        // open-ended backfill that already covers the old delivery anchor.
13303        let mut replacement_backfills = Vec::new();
13304        for (owner, interests) in &owners {
13305            let previous_filters: Vec<Filter> = self
13306                .owner_interests(owner)
13307                .map(log_filters)
13308                .unwrap_or_default();
13309            let continuity_anchor = previous_filters
13310                .iter()
13311                .filter_map(|filter| self.log_anchor(filter))
13312                .min();
13313            let filters = log_filters(interests);
13314            if let Some(backfill) = explicit_backfill
13315                && !filters.is_empty()
13316            {
13317                replacement_backfills.push(QueuedSubscriberBackfill {
13318                    owner: Some(owner.clone()),
13319                    epoch: None,
13320                    filters: filters.clone(),
13321                    backfill,
13322                });
13323            }
13324            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
13325                explicit.end_block().is_none()
13326                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
13327            });
13328            let continuity_filters: Vec<_> = filters
13329                .into_iter()
13330                .filter(|filter| !previous_filters.contains(filter))
13331                .collect();
13332            if let Some(anchor) = continuity_anchor
13333                && !continuity_filters.is_empty()
13334                && !explicit_covers
13335            {
13336                replacement_backfills.push(QueuedSubscriberBackfill {
13337                    owner: Some(owner.clone()),
13338                    epoch: None,
13339                    filters: continuity_filters,
13340                    backfill: SubscriberBackfill::from_block(anchor),
13341                });
13342            }
13343        }
13344
13345        let retained_backfills = self
13346            .pending_backfills
13347            .iter()
13348            .filter(|queued| {
13349                queued
13350                    .owner
13351                    .as_ref()
13352                    .is_none_or(|owner| !seen.contains(owner))
13353            })
13354            .map(|queued| queued.filters.len())
13355            .sum::<usize>();
13356        let replacement_units = replacement_backfills
13357            .iter()
13358            .map(|queued| queued.filters.len())
13359            .sum::<usize>();
13360        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13361        {
13362            return Err(SubscriberError::ResourceExhausted(format!(
13363                "bulk owner update would queue more than {} lazy backfills",
13364                self.config.max_pending_backfills
13365            )));
13366        }
13367
13368        // All validation and capacity checks are complete. The remaining
13369        // assignments have no failure or cancellation point, so topology and
13370        // historical work become authoritative as one local commit.
13371        self.owned_interests = next_owned;
13372        self.interests = next_registered;
13373        for owner in &seen {
13374            self.recent_compat_owner_input_refs.remove(owner);
13375            self.recent_compat_owner_input_ref_sets.remove(owner);
13376        }
13377        self.retire_unreferenced_filters();
13378        self.sources_dirty = true;
13379        self.pending_backfills.retain(|queued| {
13380            queued
13381                .owner
13382                .as_ref()
13383                .is_none_or(|owner| !seen.contains(owner))
13384        });
13385        self.pending_backfills.extend(replacement_backfills);
13386        Ok(())
13387    }
13388
13389    /// Atomically replace every compatibility owner without requesting
13390    /// historical delivery.
13391    ///
13392    /// # Errors
13393    ///
13394    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13395    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
13396    /// The previous topology remains authoritative on error.
13397    pub fn replace_interest_owners(
13398        &mut self,
13399        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13400    ) -> Result<(), SubscriberError> {
13401        self.replace_interest_owners_inner(owners, None)
13402    }
13403
13404    /// Atomically replace every compatibility owner and queue one global
13405    /// post-baseline backfill for the resulting union of log interests.
13406    ///
13407    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
13408    /// be mixed with this compatibility replacement because silently deleting
13409    /// an in-flight epoch would violate its activation transaction.
13410    ///
13411    /// # Errors
13412    ///
13413    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13414    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13415    /// exhaustion. The previous topology remains authoritative on error.
13416    pub fn replace_interest_owners_with_global_backfill(
13417        &mut self,
13418        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13419        backfill: SubscriberBackfill,
13420    ) -> Result<(), SubscriberError> {
13421        self.replace_interest_owners_inner(owners, Some(backfill))
13422    }
13423
13424    fn replace_interest_owners_inner(
13425        &mut self,
13426        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13427        backfill: Option<SubscriberBackfill>,
13428    ) -> Result<(), SubscriberError> {
13429        validate_subscriber_config(&self.config)?;
13430        if self
13431            .owned_interests
13432            .iter()
13433            .any(|entry| entry.epoch.is_some())
13434        {
13435            return Err(SubscriberError::InvalidConfig(
13436                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
13437            ));
13438        }
13439
13440        let mut seen = HashSet::with_capacity(owners.len());
13441        let mut next_owned = Vec::with_capacity(owners.len());
13442        for (owner, interests) in owners {
13443            if !seen.insert(owner.clone()) {
13444                return Err(SubscriberError::InvalidConfig(
13445                    "owner replacement contains a duplicate owner",
13446                ));
13447            }
13448            next_owned.push(OwnedSubscriberInterests {
13449                owner,
13450                interests,
13451                epoch: None,
13452                state: SubscriberOwnerState::Active,
13453                baseline: None,
13454                progress: None,
13455                progress_stream_revision: None,
13456            });
13457        }
13458        let next_registered = aggregate_interests(&[], &next_owned);
13459        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13460        let mut filters = log_filters(&next_registered);
13461        let mut unique_filters = Vec::with_capacity(filters.len());
13462        for filter in filters.drain(..) {
13463            if !unique_filters.contains(&filter) {
13464                unique_filters.push(filter);
13465            }
13466        }
13467        let replacement_backfills: VecDeque<_> = match backfill {
13468            Some(backfill) if !unique_filters.is_empty() => {
13469                VecDeque::from([QueuedSubscriberBackfill {
13470                    owner: None,
13471                    epoch: None,
13472                    filters: unique_filters,
13473                    backfill,
13474                }])
13475            }
13476            Some(_) | None => VecDeque::new(),
13477        };
13478        let replacement_units = replacement_backfills
13479            .iter()
13480            .map(|queued| queued.filters.len())
13481            .sum::<usize>();
13482        if replacement_units > self.config.max_pending_backfills {
13483            return Err(SubscriberError::ResourceExhausted(format!(
13484                "owner replacement would queue more than {} lazy backfills",
13485                self.config.max_pending_backfills
13486            )));
13487        }
13488
13489        // No fallible work remains. The post-baseline range reconstructs every
13490        // delivery after the cache snapshot, so reset all stale delivery and
13491        // dedupe state from the prior topology before publishing the exact
13492        // replacement plus its global historical work.
13493        let revoke_preconfirmation = self.latest_preconfirmation.is_some()
13494            || self.pending_preconfirmation_invalidation
13495            || self.pending_records.iter().any(|record| {
13496                record.scope == SubscriberInputScope::Preconfirmed
13497                    || matches!(
13498                        &record.record.context.chain_status,
13499                        ChainStatus::Preconfirmed { .. }
13500                    )
13501            });
13502        self.base_interests.clear();
13503        self.owned_interests = next_owned;
13504        self.interests = next_registered;
13505        self.reset_delivery_state();
13506        self.pending_preconfirmation_invalidation = revoke_preconfirmation;
13507        self.pending_backfills = replacement_backfills;
13508        self.reset_stream_topology();
13509        Ok(())
13510    }
13511
13512    /// Add or replace the interests owned by `owner`.
13513    ///
13514    /// This preserves unrelated owners, queued/pending records, recent dedupe
13515    /// state, and last-seen log anchors. The live transport is reconciled on the
13516    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
13517    /// be subscribed without rebuilding the whole subscriber object.
13518    ///
13519    /// Replacing an existing owner is continuity-safe: filters the owner
13520    /// already had keep their delivery anchors, and any changed or new filter
13521    /// shape is automatically backfilled from the owner's oldest prior anchor —
13522    /// growing a pool set on an established owner does not open a delivery gap
13523    /// for what the old subscription had already covered. A brand-new owner has
13524    /// no anchor to inherit; pass an explicit
13525    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
13526    /// anchor (or register through [`ReactiveEngine::register_handler`], which
13527    /// anchors to the runtime's last canonical block).
13528    ///
13529    /// # Errors
13530    ///
13531    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13532    /// lifecycle state, unsupported interests, or continuity-backfill capacity
13533    /// exhaustion. The prior owner state remains authoritative on error.
13534    pub fn add_interest_owner(
13535        &mut self,
13536        owner: HandlerId,
13537        interests: &[ReactiveInterest<N>],
13538    ) -> Result<(), SubscriberError> {
13539        self.set_interest_owner(owner, interests, None)
13540    }
13541
13542    /// Add or replace owner interests and schedule log backfill for that owner.
13543    ///
13544    /// Backfill is queued only for log interests; block and pending transaction
13545    /// interests are live-only. Queued records can be delivered immediately;
13546    /// the subsequent provider stream is then caught up from the seeded
13547    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
13548    /// is closed end to end as long
13549    /// as `backfill` starts at (or before) the block the interest was
13550    /// discovered in. Continuity backfill for a replaced owner (see
13551    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
13552    /// unless this explicit backfill is open-ended and already starts at or
13553    /// below the owner's prior anchor.
13554    ///
13555    /// # Errors
13556    ///
13557    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13558    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
13559    /// The prior owner state remains authoritative on error.
13560    pub fn add_interest_owner_with_backfill(
13561        &mut self,
13562        owner: HandlerId,
13563        interests: &[ReactiveInterest<N>],
13564        backfill: SubscriberBackfill,
13565    ) -> Result<(), SubscriberError> {
13566        self.set_interest_owner(owner, interests, Some(backfill))
13567    }
13568
13569    /// Add or replace one owner at retained canonical block `C`, then queue the
13570    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
13571    ///
13572    /// The new owner alone receives matching records from `C` so its effects
13573    /// attach to the runtime's existing journal entry. Every matching log from
13574    /// `C + 1` through the activation head is then delivered canonically over
13575    /// the complete interest union. [`Self::next_scoped_batch`] installs the
13576    /// desired live streams before draining either window, closing the
13577    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
13578    /// pending-transaction deliveries through this log backfill path, so a
13579    /// mixed interest topology is rejected rather than silently underfilled.
13580    ///
13581    /// # Errors
13582    ///
13583    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13584    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
13585    /// resource exhaustion. The prior owner state remains authoritative on
13586    /// error.
13587    pub fn add_interest_owner_with_canonical_catchup(
13588        &mut self,
13589        owner: HandlerId,
13590        interests: &[ReactiveInterest<N>],
13591        retained: BlockRef,
13592    ) -> Result<(), SubscriberError> {
13593        validate_subscriber_config(&self.config)?;
13594        if self
13595            .owned_interests
13596            .iter()
13597            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13598        {
13599            return Err(SubscriberError::InvalidConfig(
13600                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13601            ));
13602        }
13603
13604        let mut next_owned = self.clone_owned_interests();
13605        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
13606            entry.interests = interests.to_vec();
13607            entry.state = SubscriberOwnerState::Active;
13608            entry.baseline = None;
13609            entry.progress = None;
13610            entry.progress_stream_revision = None;
13611            entry.epoch = None;
13612        } else {
13613            next_owned.push(OwnedSubscriberInterests {
13614                owner: owner.clone(),
13615                interests: interests.to_vec(),
13616                epoch: None,
13617                state: SubscriberOwnerState::Active,
13618                baseline: None,
13619                progress: None,
13620                progress_stream_revision: None,
13621            });
13622        }
13623        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13624        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13625        if next_registered
13626            .iter()
13627            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13628        {
13629            return Err(SubscriberError::Unsupported(
13630                "Alloy coordinated registration supports log-only interest topologies",
13631            ));
13632        }
13633
13634        let mut owner_filters = Vec::new();
13635        for filter in log_filters(interests) {
13636            if !owner_filters.contains(&filter) {
13637                owner_filters.push(filter);
13638            }
13639        }
13640        let mut global_filters = Vec::new();
13641        for filter in log_filters(&next_registered) {
13642            if !global_filters.contains(&filter) {
13643                global_filters.push(filter);
13644            }
13645        }
13646        let owner_backfill =
13647            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
13648        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
13649        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
13650        let retained_units = self
13651            .pending_backfills
13652            .iter()
13653            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13654            .map(|queued| queued.filters.len())
13655            .sum::<usize>();
13656        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
13657            return Err(SubscriberError::ResourceExhausted(format!(
13658                "coordinated owner registration would queue more than {} lazy backfills",
13659                self.config.max_pending_backfills
13660            )));
13661        }
13662
13663        let mut replacement_backfills = VecDeque::new();
13664        if !owner_filters.is_empty() {
13665            replacement_backfills.push_back(QueuedSubscriberBackfill {
13666                owner: Some(owner.clone()),
13667                epoch: None,
13668                filters: owner_filters,
13669                backfill: owner_backfill,
13670            });
13671        }
13672        // Keep the global certification job even for an empty filter union: it
13673        // advances canonical coverage through a zero-event registration window.
13674        replacement_backfills.push_back(QueuedSubscriberBackfill {
13675            owner: None,
13676            epoch: None,
13677            filters: global_filters,
13678            backfill: global_backfill,
13679        });
13680
13681        // Every fallible preflight is complete. Publish topology and both
13682        // ordered windows as one synchronous local commit.
13683        self.owned_interests = next_owned;
13684        self.interests = next_registered;
13685        self.recent_compat_owner_input_refs.remove(&owner);
13686        self.recent_compat_owner_input_ref_sets.remove(&owner);
13687        self.pending_backfills
13688            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13689        self.pending_backfills.extend(replacement_backfills);
13690        self.retire_unreferenced_filters();
13691        self.sources_dirty = true;
13692        Ok(())
13693    }
13694
13695    /// Remove one owner's interests, preserving unrelated owner/base interests.
13696    ///
13697    /// The owner's queued backfills are dropped, and source-id/anchor
13698    /// bookkeeping for filters no other owner references is retired. Live
13699    /// streams for retired filters are torn down on the next
13700    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
13701    /// unsubscribes provider-side); events already in flight from them stop
13702    /// matching the merged interest set and are discarded.
13703    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
13704        let index = self
13705            .owned_interests
13706            .iter()
13707            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
13708        let removed = self.owned_interests.remove(index);
13709        if let Some(epoch) = &removed.epoch {
13710            self.purge_owner_epoch(epoch);
13711        } else {
13712            self.pending_backfills
13713                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
13714            self.recent_compat_owner_input_refs.remove(owner);
13715            self.recent_compat_owner_input_ref_sets.remove(owner);
13716        }
13717        self.rebuild_registered_interests();
13718        self.retire_unreferenced_filters();
13719        self.sources_dirty = true;
13720        Some(removed.interests)
13721    }
13722
13723    /// Borrow the interests currently owned by `owner`.
13724    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
13725        self.owned_interests
13726            .iter()
13727            .find(|entry| &entry.owner == owner)
13728            .map(|entry| entry.interests.as_slice())
13729    }
13730
13731    fn set_interest_owner(
13732        &mut self,
13733        owner: HandlerId,
13734        interests: &[ReactiveInterest<N>],
13735        backfill: Option<SubscriberBackfill>,
13736    ) -> Result<(), SubscriberError> {
13737        validate_subscriber_config(&self.config)?;
13738        if self
13739            .owned_interests
13740            .iter()
13741            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13742        {
13743            return Err(SubscriberError::InvalidConfig(
13744                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13745            ));
13746        }
13747
13748        let mut next_owned = self.clone_owned_interests();
13749        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
13750            Some(entry) => {
13751                entry.interests = interests.to_vec();
13752                entry.state = SubscriberOwnerState::Active;
13753                entry.baseline = None;
13754                entry.progress = None;
13755                entry.progress_stream_revision = None;
13756                entry.epoch.take()
13757            }
13758            None => {
13759                next_owned.push(OwnedSubscriberInterests {
13760                    owner: owner.clone(),
13761                    interests: interests.to_vec(),
13762                    epoch: None,
13763                    state: SubscriberOwnerState::Active,
13764                    baseline: None,
13765                    progress: None,
13766                    progress_stream_revision: None,
13767                });
13768                None
13769            }
13770        };
13771        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13772        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13773
13774        // Continuity capture, before the mutation lands: the owner's previous
13775        // filter shapes and the oldest delivery anchor among them. A changed
13776        // filter gets a fresh source id with no anchor, so without this
13777        // hand-off, replacing an owner's interests (the normal way to grow a
13778        // pool set) would silently discard the delivery watermark and open a
13779        // gap until some later explicit backfill.
13780        let previous_filters: Vec<Filter> = self
13781            .owner_interests(&owner)
13782            .map(log_filters)
13783            .unwrap_or_default();
13784        let continuity_anchor: Option<u64> = previous_filters
13785            .iter()
13786            .filter_map(|filter| self.log_anchor(filter))
13787            .min();
13788
13789        // Build the replacement queue before committing owner state. Capacity
13790        // failure is therefore atomic and cannot leave desired interests ahead
13791        // of the historical work required to make them continuous.
13792        let mut replacement_backfills = Vec::new();
13793        let filters = log_filters(interests);
13794        if let Some(backfill) = backfill
13795            && !filters.is_empty()
13796        {
13797            replacement_backfills.push(QueuedSubscriberBackfill {
13798                owner: Some(owner.clone()),
13799                epoch: None,
13800                filters: filters.clone(),
13801                backfill,
13802            });
13803        }
13804        let explicit_covers = backfill.is_some_and(|explicit| {
13805            explicit.end_block().is_none()
13806                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
13807        });
13808        let continuity_filters: Vec<_> = filters
13809            .into_iter()
13810            .filter(|filter| !previous_filters.contains(filter))
13811            .collect();
13812        if let Some(anchor) = continuity_anchor
13813            && !continuity_filters.is_empty()
13814            && !explicit_covers
13815        {
13816            replacement_backfills.push(QueuedSubscriberBackfill {
13817                owner: Some(owner.clone()),
13818                epoch: None,
13819                filters: continuity_filters,
13820                backfill: SubscriberBackfill::from_block(anchor),
13821            });
13822        }
13823        let retained_backfills = self
13824            .pending_backfills
13825            .iter()
13826            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13827            .map(|queued| queued.filters.len())
13828            .sum::<usize>();
13829        let replacement_units = replacement_backfills
13830            .iter()
13831            .map(|queued| queued.filters.len())
13832            .sum::<usize>();
13833        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13834        {
13835            return Err(SubscriberError::ResourceExhausted(format!(
13836                "owner update would queue more than {} lazy backfills",
13837                self.config.max_pending_backfills
13838            )));
13839        }
13840
13841        self.owned_interests = next_owned;
13842        self.interests = next_registered;
13843        if let Some(epoch) = replaced_epoch {
13844            self.purge_owner_epoch(&epoch);
13845        } else {
13846            self.recent_compat_owner_input_refs.remove(&owner);
13847            self.recent_compat_owner_input_ref_sets.remove(&owner);
13848        }
13849        self.retire_unreferenced_filters();
13850        self.sources_dirty = true;
13851
13852        // Re-queue this owner's backfills from scratch: previously queued
13853        // entries may reference filter shapes that no longer exist.
13854        self.pending_backfills
13855            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13856        self.pending_backfills.extend(replacement_backfills);
13857        Ok(())
13858    }
13859
13860    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
13861        self.owned_interests
13862            .iter()
13863            .map(|entry| OwnedSubscriberInterests {
13864                owner: entry.owner.clone(),
13865                interests: entry.interests.clone(),
13866                epoch: entry.epoch.clone(),
13867                state: entry.state,
13868                baseline: entry.baseline,
13869                progress: entry.progress.clone(),
13870                progress_stream_revision: entry.progress_stream_revision,
13871            })
13872            .collect()
13873    }
13874
13875    fn rebuild_registered_interests(&mut self) {
13876        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
13877    }
13878
13879    /// Delivery anchor (last block known fully delivered) for `filter`, if the
13880    /// filter has a source id and has seen delivery.
13881    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
13882        if let Some(anchor) = self
13883            .log_source_ids
13884            .get(filter)
13885            .and_then(|id| self.last_seen_log_blocks.get(id))
13886        {
13887            return Some(*anchor);
13888        }
13889
13890        // Logical owner filters may be represented by a broader provider
13891        // stream after fan-in. Its oldest live watermark is a conservative
13892        // continuity anchor: it can cause extra backfill, never a missed log.
13893        self.log_source_ids
13894            .values()
13895            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
13896            .min()
13897    }
13898
13899    /// Every logical log filter across base and owner interests, merged within
13900    /// each origin and deduplicated across origins. These shapes remain the
13901    /// exact routing and owner-continuity boundary; provider subscriptions may
13902    /// fan several of them into one broader filter.
13903    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13904    // `mutable_key_type` lint is a known false positive for it.
13905    #[allow(clippy::mutable_key_type)]
13906    fn logical_log_filters(&self) -> Vec<Filter> {
13907        let mut filters = log_filters(&self.base_interests);
13908        for entry in &self.owned_interests {
13909            filters.extend(log_filters(&entry.interests));
13910        }
13911        let mut seen = HashSet::new();
13912        filters.retain(|filter| seen.insert(filter.clone()));
13913        filters
13914    }
13915
13916    /// Provider-facing log filters. Compatible logical filters fan into a
13917    /// small number of address/topic supersets, then split only when the
13918    /// configured address ceiling requires it. Exact matching remains local in
13919    /// `enqueue_event`, so this reduces subscriptions without broadening owner
13920    /// delivery.
13921    fn log_stream_filters(&self) -> Vec<Filter> {
13922        let mut merged = Vec::new();
13923        for filter in self.logical_log_filters() {
13924            merge_log_subscription_filter(&mut merged, &filter);
13925        }
13926
13927        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
13928        let mut planned = Vec::new();
13929        for filter in merged {
13930            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
13931            if addresses.len() <= max_addresses {
13932                planned.push(filter);
13933                continue;
13934            }
13935            addresses.sort_unstable();
13936            for chunk in addresses.chunks(max_addresses) {
13937                let mut split = filter.clone();
13938                split.address = FilterSet::default();
13939                for address in chunk {
13940                    split.address.insert(*address);
13941                }
13942                planned.push(split);
13943            }
13944        }
13945        planned
13946    }
13947
13948    /// Drop source-id and anchor bookkeeping for filters no longer referenced
13949    /// by any base or owner interest, so long-lived owner churn cannot grow the
13950    /// maps unboundedly. Live streams for retired filters are pruned by the
13951    /// next reconcile.
13952    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13953    // `mutable_key_type` lint is a known false positive for it.
13954    #[allow(clippy::mutable_key_type)]
13955    fn retire_unreferenced_filters(&mut self) {
13956        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
13957        if let AlloySubscriberState::Active(streams) = &self.state {
13958            for entry in &streams.entries {
13959                match &entry.source {
13960                    SubscriberStreamSource::PubSubLog { filter, .. }
13961                    | SubscriberStreamSource::BasePendingLog { filter, .. }
13962                    | SubscriberStreamSource::PollingLog { filter } => {
13963                        live.insert(filter.clone());
13964                    }
13965                    SubscriberStreamSource::BaseFlashblocks
13966                    | SubscriberStreamSource::OpPendingFlashblocks
13967                    | SubscriberStreamSource::CanonicalHeadPolling
13968                    | SubscriberStreamSource::PubSubPendingHashes
13969                    | SubscriberStreamSource::PubSubBlockHeaders
13970                    | SubscriberStreamSource::PollingPendingHashes => {}
13971                    #[cfg(feature = "raw-flashblocks-json")]
13972                    SubscriberStreamSource::ExternalFlashblockUpdates => {}
13973                }
13974            }
13975        }
13976        self.log_source_ids
13977            .retain(|filter, _| live.contains(filter));
13978        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
13979        self.last_seen_log_blocks
13980            .retain(|id, _| live_ids.contains(id));
13981    }
13982
13983    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
13984        if self.pending_records.is_empty()
13985            && self.pending_chain_controls.is_empty()
13986            && !self.pending_preconfirmation_invalidation
13987        {
13988            return None;
13989        }
13990
13991        let first_preconfirmation = self.pending_records.front().and_then(|record| {
13992            if record.scope != SubscriberInputScope::Preconfirmed {
13993                return None;
13994            }
13995            match &record.record.context.chain_status {
13996                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
13997                _ => None,
13998            }
13999        });
14000        let len = self
14001            .pending_records
14002            .iter()
14003            .take(self.config.max_batch_size)
14004            .take_while(|record| match &first_preconfirmation {
14005                Some(expected) => {
14006                    record.scope == SubscriberInputScope::Preconfirmed
14007                        && matches!(
14008                            &record.record.context.chain_status,
14009                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
14010                        )
14011                }
14012                None => record.scope != SubscriberInputScope::Preconfirmed,
14013            })
14014            .count();
14015        let preconfirmation_timing = self
14016            .pending_records
14017            .iter()
14018            .take(len)
14019            .filter_map(SubscriberInputRecord::preconfirmation_timing)
14020            .reduce(FlashblockIngressTiming::earliest);
14021        let records = self.pending_records.drain(..len).collect();
14022        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
14023            self.pending_chain_controls.drain(..).collect()
14024        } else {
14025            Vec::new()
14026        };
14027        Some(SubscriberInputBatch {
14028            records,
14029            chain_id: self.chain_id,
14030            chain_controls,
14031            preconfirmation_invalidated: std::mem::take(
14032                &mut self.pending_preconfirmation_invalidation,
14033            ),
14034            preconfirmation_timing,
14035        })
14036    }
14037
14038    fn reset_delivery_state(&mut self) {
14039        self.pending_records.clear();
14040        self.pending_chain_controls.clear();
14041        self.pending_reconcile_owner_records.clear();
14042        self.resource_error = None;
14043        self.last_seen_log_blocks.clear();
14044        self.verified_log_blocks.clear();
14045        self.verified_log_block_order.clear();
14046        self.recent_input_refs.clear();
14047        self.recent_input_ref_set.clear();
14048        self.recent_owner_input_refs.clear();
14049        self.recent_owner_input_ref_sets.clear();
14050        self.recent_compat_owner_input_refs.clear();
14051        self.recent_compat_owner_input_ref_sets.clear();
14052        self.pending_backfills.clear();
14053        self.pending_source_backfills.clear();
14054        self.pending_preconfirmation_invalidation = false;
14055        self.pending_flashblock_reconnects.clear();
14056        self.pending_flashblock_reconnect_sources.clear();
14057        self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
14058        self.log_source_ids.clear();
14059        self.next_log_source_id = 0;
14060        self.sources_dirty = true;
14061        self.last_certified_canonical_head = None;
14062        self.reset_flashblock_tracking();
14063    }
14064
14065    fn reset_stream_topology(&mut self) {
14066        #[cfg(feature = "raw-flashblocks-json")]
14067        let external = match &mut self.state {
14068            AlloySubscriberState::Active(streams) => streams
14069                .entries
14070                .iter()
14071                .position(|entry| entry.source.is_external_flashblocks())
14072                .map(|index| streams.entries.remove(index)),
14073            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
14074        };
14075
14076        #[cfg(feature = "raw-flashblocks-json")]
14077        if let Some(external) = external {
14078            let mut streams = SubscriberStreams::new();
14079            streams.entries.push(external);
14080            self.state = AlloySubscriberState::Active(streams);
14081            return;
14082        }
14083
14084        self.state = AlloySubscriberState::Uninitialized;
14085    }
14086
14087    fn reset_flashblock_tracking(&mut self) {
14088        self.base_flashblock_header = None;
14089        self.base_flashblock_transactions = None;
14090        self.unmatched_pending_logs.clear();
14091        self.latest_preconfirmation = None;
14092        self.preconfirmed_seen_logs.clear();
14093        self.preconfirmed_receipted_transactions.clear();
14094        self.preconfirmed_unavailable_receipts.clear();
14095        #[cfg(feature = "raw-flashblocks-json")]
14096        {
14097            self.last_external_flashblock_snapshot = None;
14098        }
14099        self.consecutive_flashblock_poll_failures = 0;
14100    }
14101
14102    /// Revoke only the active speculative snapshot while keeping the pinned
14103    /// provider session and its streams alive. A sampled OP pending view can
14104    /// legitimately be replaced, or a provider backend can briefly return an
14105    /// older cumulative view. Either observation makes the current signing
14106    /// authority unsafe, but does not prove that the transport generation is
14107    /// broken and should be reconnected.
14108    fn invalidate_preconfirmation_snapshot(&mut self) {
14109        self.pending_records
14110            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
14111        self.pending_preconfirmation_invalidation = true;
14112        self.latest_preconfirmation = None;
14113        self.preconfirmed_seen_logs.clear();
14114        self.preconfirmed_receipted_transactions.clear();
14115        self.preconfirmed_unavailable_receipts.clear();
14116    }
14117
14118    fn bump_stream_revision(&mut self) {
14119        self.stream_revision = self.stream_revision.saturating_add(1);
14120    }
14121}
14122
14123impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
14124where
14125    P: Provider<N> + Send + Sync,
14126    N: Network + 'static,
14127    N::HeaderResponse: Send + 'static,
14128{
14129    fn upsert_interest_owners(
14130        &mut self,
14131        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14132    ) -> SubscriberOperation<'_, ()> {
14133        Box::pin(async move {
14134            if !owners.is_empty() {
14135                self.ensure_chain_id().await?;
14136            }
14137            AlloySubscriber::upsert_interest_owners(self, owners)
14138        })
14139    }
14140
14141    fn replace_interest_owners(
14142        &mut self,
14143        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14144    ) -> SubscriberOperation<'_, ()> {
14145        Box::pin(async move {
14146            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
14147                self.ensure_chain_id().await?;
14148            }
14149            AlloySubscriber::replace_interest_owners(self, owners)
14150        })
14151    }
14152
14153    fn replace_interest_owners_with_global_backfill(
14154        &mut self,
14155        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14156        backfill: SubscriberBackfill,
14157    ) -> SubscriberOperation<'_, ()> {
14158        Box::pin(async move {
14159            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
14160                self.ensure_chain_id().await?;
14161            }
14162            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
14163        })
14164    }
14165
14166    fn add_interest_owner(
14167        &mut self,
14168        owner: HandlerId,
14169        interests: &[ReactiveInterest<N>],
14170    ) -> SubscriberOperation<'_, ()> {
14171        let interests = interests.to_vec();
14172        Box::pin(async move {
14173            if !interests.is_empty() {
14174                self.ensure_chain_id().await?;
14175            }
14176            AlloySubscriber::add_interest_owner(self, owner, &interests)
14177        })
14178    }
14179
14180    fn add_interest_owner_with_backfill(
14181        &mut self,
14182        owner: HandlerId,
14183        interests: &[ReactiveInterest<N>],
14184        backfill: SubscriberBackfill,
14185    ) -> SubscriberOperation<'_, ()> {
14186        let interests = interests.to_vec();
14187        Box::pin(async move {
14188            if !interests.is_empty() {
14189                self.ensure_chain_id().await?;
14190            }
14191            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
14192        })
14193    }
14194
14195    fn add_interest_owner_with_canonical_catchup(
14196        &mut self,
14197        owner: HandlerId,
14198        interests: &[ReactiveInterest<N>],
14199        retained: BlockRef,
14200    ) -> SubscriberOperation<'_, ()> {
14201        let interests = interests.to_vec();
14202        Box::pin(async move {
14203            // Resolve provider identity before the synchronous topology commit;
14204            // cancellation or failure at this await leaves prior state intact.
14205            self.ensure_chain_id().await?;
14206            AlloySubscriber::add_interest_owner_with_canonical_catchup(
14207                self, owner, &interests, retained,
14208            )
14209        })
14210    }
14211
14212    fn remove_interest_owner(
14213        &mut self,
14214        owner: &HandlerId,
14215    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
14216        let owner = owner.clone();
14217        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
14218    }
14219
14220    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
14221        AlloySubscriber::owner_interests(self, owner)
14222    }
14223}
14224
14225enum AlloySubscriberState<N: Network> {
14226    Uninitialized,
14227    Active(SubscriberStreams<N>),
14228    Empty,
14229}
14230
14231struct SubscriberStreams<N: Network> {
14232    entries: Vec<SubscriberStreamEntry<N>>,
14233    next_index: usize,
14234}
14235
14236struct SubscriberStreamEntry<N: Network> {
14237    source: SubscriberStreamSource,
14238    stream: BoxStream<'static, SubscriberEvent<N>>,
14239}
14240
14241impl<N: Network> SubscriberStreams<N> {
14242    fn new() -> Self {
14243        Self {
14244            entries: Vec::new(),
14245            next_index: 0,
14246        }
14247    }
14248
14249    fn is_empty(&self) -> bool {
14250        self.entries.is_empty()
14251    }
14252
14253    fn push(
14254        &mut self,
14255        source: SubscriberStreamSource,
14256        stream: BoxStream<'static, SubscriberEvent<N>>,
14257    ) {
14258        self.entries.push(SubscriberStreamEntry { source, stream });
14259    }
14260
14261    #[cfg(all(test, any(feature = "reactive-polling", feature = "reactive-ws")))]
14262    fn len(&self) -> usize {
14263        self.entries.len()
14264    }
14265
14266    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
14267        self.entries
14268            .iter()
14269            .any(|entry| entry.source.same_key(source))
14270    }
14271
14272    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
14273        self.entries
14274            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
14275        self.normalize_next_index();
14276    }
14277
14278    fn normalize_next_index(&mut self) {
14279        if self.entries.is_empty() {
14280            self.next_index = 0;
14281        } else if self.next_index >= self.entries.len() {
14282            self.next_index %= self.entries.len();
14283        }
14284    }
14285
14286    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
14287        poll_fn(|cx| {
14288            self.normalize_next_index();
14289            if self.entries.is_empty() {
14290                return std::task::Poll::Ready(None);
14291            }
14292
14293            let mut index = self.next_index;
14294            let mut checked = 0usize;
14295            while checked < self.entries.len() {
14296                if index >= self.entries.len() {
14297                    index = 0;
14298                }
14299                match self.entries[index].stream.as_mut().poll_next(cx) {
14300                    std::task::Poll::Ready(Some(event)) => {
14301                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
14302                            self.entries.remove(index);
14303                            self.next_index = if self.entries.is_empty() {
14304                                0
14305                            } else {
14306                                index % self.entries.len()
14307                            };
14308                        } else {
14309                            self.next_index = (index + 1) % self.entries.len();
14310                        }
14311                        return std::task::Poll::Ready(Some(event));
14312                    }
14313                    std::task::Poll::Ready(None) => {
14314                        self.entries.remove(index);
14315                        if self.entries.is_empty() {
14316                            self.next_index = 0;
14317                            return std::task::Poll::Ready(None);
14318                        }
14319                    }
14320                    std::task::Poll::Pending => {
14321                        checked += 1;
14322                        index += 1;
14323                    }
14324                }
14325            }
14326
14327            if self.entries.is_empty() {
14328                std::task::Poll::Ready(None)
14329            } else {
14330                self.next_index = index % self.entries.len();
14331                std::task::Poll::Pending
14332            }
14333        })
14334        .await
14335    }
14336}
14337
14338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14339#[allow(dead_code)]
14340enum SubscriberTransport {
14341    PubSub,
14342    Polling,
14343}
14344
14345#[derive(Clone, Debug)]
14346enum SubscriberStreamSource {
14347    PubSubLog {
14348        id: usize,
14349        filter: Filter,
14350    },
14351    BasePendingLog {
14352        id: usize,
14353        filter: Filter,
14354    },
14355    BaseFlashblocks,
14356    OpPendingFlashblocks,
14357    CanonicalHeadPolling,
14358    PubSubPendingHashes,
14359    PubSubBlockHeaders,
14360    PollingLog {
14361        filter: Filter,
14362    },
14363    PollingPendingHashes,
14364    #[cfg(feature = "raw-flashblocks-json")]
14365    ExternalFlashblockUpdates,
14366}
14367
14368impl SubscriberStreamSource {
14369    fn label(&self) -> &'static str {
14370        match self {
14371            Self::PubSubLog { .. } => "pubsub log",
14372            Self::BasePendingLog { .. } => "OP Stack pendingLogs",
14373            Self::BaseFlashblocks => "OP Stack newFlashblocks",
14374            Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
14375            Self::CanonicalHeadPolling => "certified canonical head",
14376            Self::PubSubPendingHashes => "pubsub pending transaction hash",
14377            Self::PubSubBlockHeaders => "pubsub block header",
14378            Self::PollingLog { .. } => "polling log",
14379            Self::PollingPendingHashes => "polling pending transaction hash",
14380            #[cfg(feature = "raw-flashblocks-json")]
14381            Self::ExternalFlashblockUpdates => "external standardized Flashblock update",
14382        }
14383    }
14384
14385    fn is_pubsub(&self) -> bool {
14386        matches!(
14387            self,
14388            Self::PubSubLog { .. }
14389                | Self::BasePendingLog { .. }
14390                | Self::BaseFlashblocks
14391                | Self::OpPendingFlashblocks
14392                | Self::PubSubPendingHashes
14393                | Self::PubSubBlockHeaders
14394        )
14395    }
14396
14397    fn is_flashblocks(&self) -> bool {
14398        matches!(
14399            self,
14400            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
14401        )
14402    }
14403
14404    fn same_key(&self, other: &Self) -> bool {
14405        match (self, other) {
14406            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
14407            | (
14408                Self::BasePendingLog { filter: left, .. },
14409                Self::BasePendingLog { filter: right, .. },
14410            )
14411            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
14412                left == right
14413            }
14414            (Self::BaseFlashblocks, Self::BaseFlashblocks)
14415            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
14416            | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
14417            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
14418            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
14419            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
14420            #[cfg(feature = "raw-flashblocks-json")]
14421            (Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true,
14422            _ => false,
14423        }
14424    }
14425
14426    fn is_external_flashblocks(&self) -> bool {
14427        #[cfg(feature = "raw-flashblocks-json")]
14428        {
14429            matches!(self, Self::ExternalFlashblockUpdates)
14430        }
14431        #[cfg(not(feature = "raw-flashblocks-json"))]
14432        {
14433            false
14434        }
14435    }
14436}
14437
14438#[allow(dead_code)]
14439enum SubscriberEvent<N: Network> {
14440    Log {
14441        source_id: usize,
14442        log: Log,
14443    },
14444    BackfilledLogs {
14445        source_id: usize,
14446        logs: Vec<Log>,
14447    },
14448    Logs(Vec<Log>),
14449    BlockHeader(N::HeaderResponse),
14450    PendingHash(B256),
14451    PendingHashes(Vec<B256>),
14452    BasePendingLog {
14453        source_id: usize,
14454        log: Log,
14455    },
14456    BasePendingLogTimed {
14457        source_id: usize,
14458        log: Log,
14459        timing: FlashblockIngressTiming,
14460    },
14461    BaseFlashblock(BaseFlashblockWirePayload),
14462    BaseFlashblockTimed {
14463        payload: BaseFlashblockWirePayload,
14464        timing: FlashblockIngressTiming,
14465    },
14466    OpFlashblockTick,
14467    OpFlashblockTickTimed(FlashblockIngressTiming),
14468    CanonicalHeadTick,
14469    PreconfirmedLogs {
14470        flashblock: FlashblockRef,
14471        logs: Vec<Log>,
14472        timing: FlashblockIngressTiming,
14473    },
14474    FlashblockInvalidated,
14475    FlashblockObserved,
14476    #[cfg(feature = "raw-flashblocks-json")]
14477    ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate),
14478    StreamTerminated(SubscriberStreamSource),
14479}
14480
14481enum SubscriberReady<N: Network> {
14482    Event(Option<SubscriberEvent<N>>),
14483    FlashblockReconnect(
14484        SubscriberStreamSource,
14485        Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
14486    ),
14487}
14488
14489#[derive(Debug)]
14490enum PendingFlashblockPollError {
14491    Request(SubscriberError),
14492    Integrity(SubscriberError),
14493}
14494
14495impl PendingFlashblockPollError {
14496    fn into_subscriber(self) -> SubscriberError {
14497        match self {
14498            Self::Request(error) | Self::Integrity(error) => error,
14499        }
14500    }
14501}
14502
14503fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
14504    PendingFlashblockPollError::Request(provider_error(error))
14505}
14506
14507fn normalize_op_pending_block<N: Network>(
14508    mut value: serde_json::Value,
14509) -> Result<N::BlockResponse, SubscriberError> {
14510    let object = value.as_object_mut().ok_or_else(|| {
14511        SubscriberError::Provider("OP pending block response is not an object".into())
14512    })?;
14513    let transactions = object
14514        .get_mut("transactions")
14515        .and_then(serde_json::Value::as_array_mut)
14516        .ok_or_else(|| {
14517            SubscriberError::Provider(
14518                "OP pending block response is missing its transaction array".into(),
14519            )
14520        })?;
14521    for transaction in transactions {
14522        if transaction.is_string() {
14523            continue;
14524        }
14525        let hash = transaction
14526            .as_object()
14527            .and_then(|object| object.get("hash"))
14528            .filter(|hash| hash.is_string())
14529            .cloned()
14530            .ok_or_else(|| {
14531                SubscriberError::Provider("OP pending block transaction is missing its hash".into())
14532            })?;
14533        *transaction = hash;
14534    }
14535    if object.get("hash").is_none_or(serde_json::Value::is_null) {
14536        object.insert(
14537            "hash".into(),
14538            serde_json::Value::String(B256::ZERO.to_string()),
14539        );
14540    }
14541    if object.get("nonce").is_none_or(serde_json::Value::is_null) {
14542        object.insert(
14543            "nonce".into(),
14544            serde_json::Value::String("0x0000000000000000".into()),
14545        );
14546    }
14547    if object.get("miner").is_none_or(serde_json::Value::is_null)
14548        || object
14549            .get("beneficiary")
14550            .is_none_or(serde_json::Value::is_null)
14551    {
14552        object.insert(
14553            "miner".into(),
14554            serde_json::Value::String(Address::ZERO.to_string()),
14555        );
14556    }
14557    serde_json::from_value(value).map_err(|error| {
14558        SubscriberError::Provider(format!(
14559            "failed to decode normalized OP pending block: {error}"
14560        ))
14561    })
14562}
14563
14564fn normalize_pending_transaction_receipt(
14565    expected_transaction_hash: B256,
14566    value: serde_json::Value,
14567) -> Result<Option<Vec<Log>>, SubscriberError> {
14568    if value.is_null() {
14569        return Ok(None);
14570    }
14571    let receipt = value.as_object().ok_or_else(|| {
14572        SubscriberError::Provider("pending transaction receipt response is not an object".into())
14573    })?;
14574    let transaction_hash: B256 =
14575        serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
14576            SubscriberError::Provider(
14577                "pending transaction receipt is missing its transaction hash".into(),
14578            )
14579        })?)
14580        .map_err(|error| {
14581            SubscriberError::Provider(format!(
14582                "failed to decode pending transaction receipt hash: {error}"
14583            ))
14584        })?;
14585    if transaction_hash != expected_transaction_hash {
14586        return Err(SubscriberError::Provider(
14587            "pending transaction receipt hash disagrees with its request".into(),
14588        ));
14589    }
14590    let receipt_logs = receipt
14591        .get("logs")
14592        .and_then(serde_json::Value::as_array)
14593        .ok_or_else(|| {
14594            SubscriberError::Provider("pending transaction receipt is missing its log array".into())
14595        })?;
14596    let mut logs = Vec::new();
14597    for log in receipt_logs {
14598        let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
14599            SubscriberError::Provider(format!(
14600                "failed to decode pending transaction receipt log: {error}"
14601            ))
14602        })?;
14603        if log.transaction_hash != Some(expected_transaction_hash) {
14604            return Err(SubscriberError::Provider(
14605                "pending transaction receipt log hash disagrees with its receipt".into(),
14606            ));
14607        }
14608        logs.push(log);
14609    }
14610    Ok(Some(logs))
14611}
14612
14613impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
14614where
14615    P: Provider<N> + Send + Sync,
14616    N: Network + 'static,
14617    N::HeaderResponse: Send + 'static,
14618{
14619    fn chain_id(&self) -> Option<u64> {
14620        self.chain_id
14621    }
14622
14623    fn capabilities(&self) -> SubscriberCapabilities {
14624        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
14625            return SubscriberCapabilities::default();
14626        };
14627        let mut capabilities = vec![
14628            SubscriberCapability::Logs,
14629            SubscriberCapability::PendingTransactionHashes,
14630            SubscriberCapability::HistoricalBackfill,
14631            SubscriberCapability::Live,
14632            SubscriberCapability::OwnerScopedDelivery,
14633            SubscriberCapability::DynamicInterests,
14634        ];
14635        if transport == SubscriberTransport::PubSub {
14636            capabilities.push(SubscriberCapability::BlockHeaders);
14637        }
14638        if self.config.preconfirmations != PreconfirmationMode::Disabled
14639            && (self.uses_external_flashblock_updates()
14640                || (self.provider_ref.is_some()
14641                    && self.chain_id.and_then(flashblocks_adapter).is_some()))
14642        {
14643            capabilities.push(SubscriberCapability::Preconfirmations);
14644        }
14645        SubscriberCapabilities::new(capabilities)
14646    }
14647
14648    fn register_interests(
14649        &mut self,
14650        interests: &[ReactiveInterest<N>],
14651    ) -> SubscriberOperation<'_, ()> {
14652        let interests = interests.to_vec();
14653        Box::pin(async move {
14654            validate_subscriber_config(&self.config)?;
14655            validate_supported_interests(self.mode, &self.config, &interests)?;
14656            if !interests.is_empty() {
14657                self.ensure_chain_id().await?;
14658            }
14659            self.validate_flashblocks_setup()?;
14660
14661            self.base_interests = interests;
14662            self.owned_interests.clear();
14663            self.rebuild_registered_interests();
14664            self.reset_delivery_state();
14665            self.reset_stream_topology();
14666            Ok(())
14667        })
14668    }
14669
14670    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
14671        Box::pin(async {
14672            Ok(self
14673                .next_scoped_batch()
14674                .await?
14675                .map(SubscriberInputBatch::into_reactive_batch))
14676        })
14677    }
14678}
14679
14680impl<P, N> AlloySubscriber<P, N>
14681where
14682    P: Provider<N> + Send + Sync,
14683    N: Network + 'static,
14684    N::HeaderResponse: Send + 'static,
14685{
14686    /// Validate one configured provider generation and establish its selected
14687    /// Flashblocks delivery surface.
14688    ///
14689    /// The caller must register at least one active log interest first. The
14690    /// built-in profiles require a matching chain id and stable [`ProviderRef`].
14691    /// Base additionally requires pubsub, `newFlashblocks`, and one
14692    /// `pendingLogs` acknowledgement per planned provider filter. Optimism
14693    /// probes the bounded pending block/log/receipt surface.
14694    /// `op_supportedCapabilities` is queried opportunistically and retained as
14695    /// opaque evidence because provider implementations do not expose a uniform
14696    /// capability vocabulary.
14697    ///
14698    /// With `raw-flashblocks-json` and
14699    /// [`Self::configure_external_flashblock_updates`], preflight instead verifies
14700    /// the canonical subscriber chain and installed canonical stream topology.
14701    /// The application owns supplemental-source qualification, and this method
14702    /// performs no Flashblocks request/response calls for that profile.
14703    ///
14704    /// A successful return is deliberately not a liveness qualification. The
14705    /// acceptance window must still observe a Flashblock whose pending state
14706    /// advances and a correlated log for an active pool.
14707    pub async fn establish_flashblocks_preflight(
14708        &mut self,
14709        expected_chain_id: u64,
14710    ) -> Result<FlashblocksPreflight, SubscriberError> {
14711        validate_subscriber_config(&self.config)?;
14712        if self.config.preconfirmations == PreconfirmationMode::Disabled {
14713            return Err(SubscriberError::InvalidConfig(
14714                "Flashblocks preflight requires preconfirmations",
14715            ));
14716        }
14717        if !self
14718            .interests
14719            .iter()
14720            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
14721        {
14722            return Err(SubscriberError::InvalidConfig(
14723                "Flashblocks preflight requires at least one active log interest",
14724            ));
14725        }
14726        let chain_id = self.ensure_chain_id().await?;
14727        if chain_id != expected_chain_id {
14728            return Err(SubscriberError::ChainMismatch {
14729                expected: expected_chain_id,
14730                actual: chain_id,
14731            });
14732        }
14733        self.validate_flashblocks_setup()?;
14734        #[cfg(feature = "raw-flashblocks-json")]
14735        if let Some(provider) = self.external_flashblocks_provider.clone() {
14736            self.ensure_streams().await?;
14737            return Ok(FlashblocksPreflight {
14738                chain_id,
14739                provider,
14740                delivery: FlashblocksDelivery::ExternalUpdates,
14741                pending_log_subscriptions: 0,
14742                pending_log_filters: self.log_stream_filters().len(),
14743                advertised_capabilities: None,
14744            });
14745        }
14746        let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
14747            "Flashblocks are currently implemented for Base and OP chains",
14748        ))?;
14749        let provider = self
14750            .provider_ref
14751            .clone()
14752            .ok_or(SubscriberError::InvalidConfig(
14753                "Flashblocks preflight requires a stable provider ref",
14754            ))?;
14755        self.flashblocks_rpc_metrics.capability_requests = self
14756            .flashblocks_rpc_metrics
14757            .capability_requests
14758            .saturating_add(1);
14759        let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
14760            self.flashblocks_state_provider
14761                .as_ref()
14762                .unwrap_or(&self.provider)
14763        } else {
14764            &self.provider
14765        };
14766        let advertised_capabilities = capability_provider
14767            .client()
14768            .request::<_, serde_json::Value>("op_supportedCapabilities", ())
14769            .await
14770            .ok();
14771
14772        self.ensure_streams().await?;
14773        let pending_log_filters = self.log_stream_filters();
14774        if adapter == FlashblocksAdapter::PendingStatePolling
14775            && self.pending_receipt_requests_per_tick_capacity() == 0
14776        {
14777            return Err(SubscriberError::InvalidConfig(
14778                "Flashblocks RPC budget leaves no capacity for OP transaction receipts",
14779            ));
14780        }
14781        let (delivery, pending_log_subscriptions) = match adapter {
14782            FlashblocksAdapter::NativeSubscriptions => {
14783                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
14784                    return Err(SubscriberError::Unsupported(
14785                        "Base Flashblocks preflight requires pubsub",
14786                    ));
14787                }
14788                let pending_sources = self
14789                    .pubsub_stream_sources()
14790                    .into_iter()
14791                    .filter(|source| {
14792                        matches!(source, SubscriberStreamSource::BasePendingLog { .. })
14793                    })
14794                    .collect::<Vec<_>>();
14795                let AlloySubscriberState::Active(streams) = &self.state else {
14796                    return Err(SubscriberError::Provider(
14797                        "Flashblocks preflight subscriptions did not become active".to_owned(),
14798                    ));
14799                };
14800                if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
14801                    || pending_sources
14802                        .iter()
14803                        .any(|source| !streams.contains_source(source))
14804                {
14805                    return Err(SubscriberError::Provider(
14806                        "Base Flashblocks preflight did not retain both subscription lanes"
14807                            .to_owned(),
14808                    ));
14809                }
14810                (
14811                    FlashblocksDelivery::NativeSubscriptions,
14812                    pending_sources.len(),
14813                )
14814            }
14815            FlashblocksAdapter::PendingStatePolling => {
14816                if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
14817                    self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
14818                        .flashblocks_rpc_metrics
14819                        .provider_pair_chain_requests
14820                        .saturating_add(1);
14821                    let actual = state_provider
14822                        .get_chain_id()
14823                        .await
14824                        .map_err(provider_error)?;
14825                    if actual != expected_chain_id {
14826                        return Err(SubscriberError::ChainMismatch {
14827                            expected: expected_chain_id,
14828                            actual,
14829                        });
14830                    }
14831                }
14832                let AlloySubscriberState::Active(streams) = &self.state else {
14833                    return Err(SubscriberError::Provider(
14834                        "Flashblocks preflight streams did not become active".to_owned(),
14835                    ));
14836                };
14837                if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
14838                    return Err(SubscriberError::Provider(
14839                        "Optimism Flashblocks preflight did not retain its pending-state sampler"
14840                            .to_owned(),
14841                    ));
14842                }
14843                self.probe_pending_state(&pending_log_filters).await?;
14844                (FlashblocksDelivery::PendingStatePolling, 0)
14845            }
14846        };
14847        Ok(FlashblocksPreflight {
14848            chain_id,
14849            provider,
14850            delivery,
14851            pending_log_subscriptions,
14852            pending_log_filters: pending_log_filters.len(),
14853            advertised_capabilities,
14854        })
14855    }
14856
14857    /// Ingest one standardized update from an application-managed source.
14858    ///
14859    /// This method is synchronous and performs no provider I/O. The update is
14860    /// validated against the configured source identity, normalized through
14861    /// the same preconfirmation deduplication used by provider subscriptions,
14862    /// and queued for ordinary [`EventSubscriber`] delivery. Stale provider
14863    /// generations and stale invalidations cannot revoke newer speculative
14864    /// state. Indexed snapshots must begin at zero, advance exactly one index at
14865    /// a time, preserve their base identity and cumulative transaction prefix,
14866    /// and bind delta logs only to newly appended transactions.
14867    #[cfg(feature = "raw-flashblocks-json")]
14868    pub fn ingest_flashblock_update(
14869        &mut self,
14870        update: FlashblockUpdate,
14871    ) -> Result<(), SubscriberError> {
14872        self.ingest_flashblock_update_with_ingress(
14873            update,
14874            FlashblockIngressTiming::new(Instant::now()),
14875        )
14876    }
14877
14878    /// Ingest one standardized update with its original typed source arrival.
14879    ///
14880    /// This timing is observability-only and cannot mutate canonical state or
14881    /// grant trigger authority.
14882    ///
14883    /// # Errors
14884    ///
14885    /// Returns the same validation and resource errors as
14886    /// [`Self::ingest_flashblock_update`].
14887    #[cfg(feature = "raw-flashblocks-json")]
14888    pub fn ingest_flashblock_update_with_ingress(
14889        &mut self,
14890        update: FlashblockUpdate,
14891        timing: FlashblockIngressTiming,
14892    ) -> Result<(), SubscriberError> {
14893        validate_subscriber_config(&self.config)?;
14894        self.validate_flashblocks_setup()?;
14895        let configured =
14896            self.external_flashblocks_provider
14897                .as_ref()
14898                .ok_or(SubscriberError::InvalidConfig(
14899                    "standardized Flashblock updates require configure_external_flashblock_updates",
14900                ))?;
14901
14902        match update {
14903            FlashblockUpdate::Snapshot(batch) => {
14904                if batch.flashblock.provider.endpoint != configured.endpoint {
14905                    return Err(SubscriberError::Provider(
14906                        "external Flashblock update came from an unexpected provider endpoint"
14907                            .into(),
14908                    ));
14909                }
14910                if batch.flashblock.provider.generation < configured.generation
14911                    || self.latest_preconfirmation.as_ref().is_some_and(|latest| {
14912                        latest.provider.endpoint == batch.flashblock.provider.endpoint
14913                            && latest.provider.generation > batch.flashblock.provider.generation
14914                    })
14915                {
14916                    return Ok(());
14917                }
14918                if self
14919                    .rejected_external_flashblock_generation
14920                    .is_some_and(|rejected| batch.flashblock.provider.generation <= rejected)
14921                {
14922                    return Err(SubscriberError::Provider(
14923                        "external Flashblock provider generation was previously rejected".into(),
14924                    ));
14925                }
14926                validate_standard_flashblock_snapshot(&batch)?;
14927                if self.validate_external_flashblock_sequence(&batch)? {
14928                    return Ok(());
14929                }
14930                let required = self.pending_record_count().saturating_add(batch.logs.len());
14931                if required > self.config.max_pending_records {
14932                    self.invalidate_preconfirmation_snapshot();
14933                    self.last_external_flashblock_snapshot = None;
14934                    return Err(SubscriberError::ResourceExhausted(format!(
14935                        "external preconfirmation records require {required} pending records, above the configured limit of {}",
14936                        self.config.max_pending_records
14937                    )));
14938                }
14939                let accepted_snapshot = (*batch).clone();
14940                let FlashblockSnapshot { flashblock, logs } = *batch;
14941                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
14942                self.last_external_flashblock_snapshot = Some(accepted_snapshot);
14943                if let Some(provider) = self.external_flashblocks_provider.as_mut() {
14944                    provider.generation = provider.generation.max(flashblock.provider.generation);
14945                }
14946                if !logs.is_empty() {
14947                    self.enqueue_event(SubscriberEvent::PreconfirmedLogs {
14948                        flashblock,
14949                        logs,
14950                        timing,
14951                    });
14952                }
14953            }
14954            FlashblockUpdate::Invalidated(invalidation) => {
14955                if invalidation.provider.endpoint != configured.endpoint {
14956                    return Err(SubscriberError::Provider(
14957                        "external Flashblock invalidation came from an unexpected provider endpoint"
14958                            .into(),
14959                    ));
14960                }
14961                if self.latest_preconfirmation.as_ref().is_some_and(|latest| {
14962                    latest.provider == invalidation.provider
14963                        && latest.payload_id == Some(invalidation.payload_id)
14964                }) {
14965                    self.invalidate_preconfirmation_snapshot();
14966                    self.last_external_flashblock_snapshot = None;
14967                }
14968            }
14969        }
14970        Ok(())
14971    }
14972
14973    #[cfg(feature = "raw-flashblocks-json")]
14974    fn validate_external_flashblock_sequence(
14975        &self,
14976        snapshot: &FlashblockSnapshot,
14977    ) -> Result<bool, SubscriberError> {
14978        let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else {
14979            if snapshot.flashblock.index != Some(0) {
14980                return Err(SubscriberError::Provider(
14981                    "external Flashblock payload generation must begin at index zero".into(),
14982                ));
14983            }
14984            return Ok(false);
14985        };
14986
14987        if previous.flashblock.provider == snapshot.flashblock.provider
14988            && previous.flashblock.payload_id == snapshot.flashblock.payload_id
14989        {
14990            let previous_index = previous
14991                .flashblock
14992                .index
14993                .expect("validated indexed snapshot");
14994            let current_index = snapshot
14995                .flashblock
14996                .index
14997                .expect("validated indexed snapshot");
14998            if current_index == previous_index {
14999                if previous == snapshot {
15000                    return Ok(true);
15001                }
15002                return Err(SubscriberError::Provider(
15003                    "external Flashblock repeated the same index with conflicting content".into(),
15004                ));
15005            }
15006            if current_index < previous_index {
15007                return Err(SubscriberError::Provider(format!(
15008                    "external Flashblock index regressed from {previous_index} to {current_index}"
15009                )));
15010            }
15011            if current_index > previous_index.saturating_add(1) {
15012                return Err(SubscriberError::Provider(format!(
15013                    "external Flashblock index skipped from {previous_index} to {current_index}"
15014                )));
15015            }
15016            if current_index == previous_index.saturating_add(1)
15017                && !previous.flashblock.same_base_identity(&snapshot.flashblock)
15018            {
15019                return Err(SubscriberError::Provider(
15020                    "external Flashblock base identity changed within one payload generation"
15021                        .into(),
15022                ));
15023            }
15024            if current_index == previous_index.saturating_add(1)
15025                && !snapshot
15026                    .flashblock
15027                    .transaction_hashes
15028                    .starts_with(&previous.flashblock.transaction_hashes)
15029            {
15030                return Err(SubscriberError::Provider(
15031                    "external Flashblock cumulative transaction membership changed its prior prefix"
15032                        .into(),
15033                ));
15034            }
15035            let prior_transaction_count =
15036                u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX);
15037            if current_index == previous_index.saturating_add(1)
15038                && snapshot.logs.iter().any(|log| {
15039                    log.transaction_index
15040                        .is_some_and(|index| index < prior_transaction_count)
15041                })
15042            {
15043                return Err(SubscriberError::Provider(
15044                    "external Flashblock delta log does not belong to a newly appended transaction"
15045                        .into(),
15046                ));
15047            }
15048        } else if snapshot.flashblock.index != Some(0) {
15049            return Err(SubscriberError::Provider(
15050                "external Flashblock payload generation must begin at index zero".into(),
15051            ));
15052        }
15053        Ok(false)
15054    }
15055
15056    async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
15057        self.flashblocks_rpc_metrics.pending_block_requests = self
15058            .flashblocks_rpc_metrics
15059            .pending_block_requests
15060            .saturating_add(1);
15061        let pending = self
15062            .fetch_op_pending_block()
15063            .await
15064            .map_err(PendingFlashblockPollError::into_subscriber)?
15065            .ok_or_else(|| {
15066                SubscriberError::Provider(
15067                    "provider returned no pending block during Flashblocks preflight".into(),
15068                )
15069            })?;
15070        self.certify_op_pending_parent(&pending)
15071            .await
15072            .map_err(PendingFlashblockPollError::into_subscriber)?;
15073        for filter in filters {
15074            self.flashblocks_rpc_metrics.pending_log_requests = self
15075                .flashblocks_rpc_metrics
15076                .pending_log_requests
15077                .saturating_add(1);
15078            self.flashblocks_state_provider
15079                .as_ref()
15080                .unwrap_or(&self.provider)
15081                .get_logs(
15082                    &filter
15083                        .clone()
15084                        .from_block(BlockNumberOrTag::Latest)
15085                        .to_block(BlockNumberOrTag::Pending),
15086                )
15087                .await
15088                .map_err(provider_error)?;
15089        }
15090        self.flashblocks_rpc_metrics.pending_receipt_requests = self
15091            .flashblocks_rpc_metrics
15092            .pending_receipt_requests
15093            .saturating_add(1);
15094        let _: serde_json::Value = self
15095            .flashblocks_state_provider
15096            .as_ref()
15097            .unwrap_or(&self.provider)
15098            .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
15099            .await
15100            .map_err(provider_error)?;
15101        Ok(())
15102    }
15103
15104    async fn certify_op_pending_parent(
15105        &mut self,
15106        pending: &N::BlockResponse,
15107    ) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
15108        let pending_header = pending.header();
15109        let pending_number = pending_header.number();
15110        let parent_hash = pending_header.parent_hash();
15111        if pending_number == 0 || parent_hash.is_zero() {
15112            return Err(PendingFlashblockPollError::Integrity(
15113                SubscriberError::Provider(
15114                    "OP pending block omitted a certifiable canonical parent".into(),
15115                ),
15116            ));
15117        }
15118        self.flashblocks_rpc_metrics.canonical_head_requests = self
15119            .flashblocks_rpc_metrics
15120            .canonical_head_requests
15121            .saturating_add(1);
15122        let parent = self
15123            .flashblocks_state_provider
15124            .as_ref()
15125            .unwrap_or(&self.provider)
15126            .get_block_by_hash(parent_hash)
15127            .await
15128            .map_err(pending_flashblock_request_error)?
15129            .ok_or_else(|| {
15130                PendingFlashblockPollError::Request(SubscriberError::Provider(
15131                    "Flashblocks provider returned no exact OP pending parent block".into(),
15132                ))
15133            })?;
15134        let parent_header = parent.header();
15135        if parent_header.hash() != parent_hash
15136            || parent_header.number().checked_add(1) != Some(pending_number)
15137        {
15138            return Err(PendingFlashblockPollError::Integrity(
15139                SubscriberError::Provider(
15140                    "OP pending block does not extend its exact certified parent".into(),
15141                ),
15142            ));
15143        }
15144        Ok(parent_header.clone())
15145    }
15146
15147    async fn fetch_op_pending_block(
15148        &mut self,
15149    ) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
15150        let state_provider = self
15151            .flashblocks_state_provider
15152            .as_ref()
15153            .unwrap_or(&self.provider);
15154        let value: Option<serde_json::Value> = state_provider
15155            .raw_request(
15156                Cow::Borrowed("eth_getBlockByNumber"),
15157                (BlockNumberOrTag::Pending, true),
15158            )
15159            .await
15160            .map_err(pending_flashblock_request_error)?;
15161        value
15162            .map(normalize_op_pending_block::<N>)
15163            .transpose()
15164            .map_err(PendingFlashblockPollError::Integrity)
15165    }
15166
15167    /// Resolve the provider's chain identity once. The assignment happens only
15168    /// after a complete RPC response, so cancelling the future leaves the
15169    /// subscriber cleanly retryable.
15170    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
15171        if let Some(chain_id) = self.chain_id {
15172            return Ok(chain_id);
15173        }
15174        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
15175        self.chain_id = Some(chain_id);
15176        Ok(chain_id)
15177    }
15178
15179    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
15180        if self.config.preconfirmations == PreconfirmationMode::Disabled {
15181            if self.uses_external_flashblock_updates() {
15182                return Err(SubscriberError::InvalidConfig(
15183                    "external Flashblock updates require preconfirmations to be preferred or required",
15184                ));
15185            }
15186            return Ok(());
15187        }
15188        if self.uses_external_flashblock_updates() {
15189            return Ok(());
15190        }
15191        if self.provider_ref.is_none() {
15192            return Err(SubscriberError::InvalidConfig(
15193                "Flashblocks require a stable provider ref from a pinned provider lease",
15194            ));
15195        }
15196        let Some(chain_id) = self.chain_id else {
15197            return Ok(());
15198        };
15199        match flashblocks_adapter(chain_id) {
15200            Some(FlashblocksAdapter::NativeSubscriptions)
15201                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
15202                    && self.config.preconfirmations == PreconfirmationMode::Required =>
15203            {
15204                return Err(SubscriberError::Unsupported(
15205                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
15206                ));
15207            }
15208            Some(FlashblocksAdapter::NativeSubscriptions) => {}
15209            Some(_) => {}
15210            None if self.config.preconfirmations == PreconfirmationMode::Required => {
15211                return Err(SubscriberError::Unsupported(
15212                    "Flashblocks are currently implemented for Base and OP chains",
15213                ));
15214            }
15215            None => {}
15216        }
15217        Ok(())
15218    }
15219
15220    /// Subscribe first, then catch an exact staged owner up through a verified
15221    /// canonical block.
15222    ///
15223    /// This compatibility wrapper delegates to
15224    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
15225    /// driver adopting several owners should call the bulk API once rather than
15226    /// invoking this method in a loop.
15227    ///
15228    /// # Errors
15229    ///
15230    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
15231    /// baseline, conflicts/regresses, provider certification or transport
15232    /// fails, returned logs are invalid, or subscriber resources are exhausted.
15233    pub async fn reconcile_interest_owner(
15234        &mut self,
15235        epoch: &SubscriberOwnerEpoch,
15236        through: BlockRef,
15237    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
15238    where
15239        P: Clone,
15240    {
15241        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
15242            .await?
15243            .pop()
15244            .ok_or(SubscriberOwnerError::NotStaged)
15245    }
15246
15247    /// Subscribe first, then atomically catch staged owners up through one
15248    /// verified canonical block.
15249    ///
15250    /// All epochs are preflighted before provider I/O. Live streams are
15251    /// reconciled once, compatible provider filters are merged into bounded
15252    /// chunks, and every historical request shares one double target-header
15253    /// certification. Provider-filter supersets are routed back through each
15254    /// owner's exact interests, retaining owner-scoped delivery provenance.
15255    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
15256    ///
15257    /// Live events are continuously drained while an independent provider
15258    /// clone performs catch-up. Fetched owner records and progress become
15259    /// visible only after every request and the final certification succeed. A
15260    /// failure leaves every target staged with its prior progress unchanged;
15261    /// live canonical delivery consumed during the attempt is preserved while
15262    /// excluding the failed target epochs from its staged-owner audience.
15263    ///
15264    /// # Errors
15265    ///
15266    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
15267    /// baseline, conflicts/regresses, provider certification or transport
15268    /// fails, returned logs are invalid, or subscriber resources are exhausted.
15269    /// Target progress remains unchanged on error.
15270    pub async fn reconcile_interest_owners(
15271        &mut self,
15272        epochs: &[SubscriberOwnerEpoch],
15273        through: BlockRef,
15274    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
15275    where
15276        P: Clone,
15277    {
15278        if epochs.is_empty() {
15279            return Ok(Vec::new());
15280        }
15281
15282        self.ensure_chain_id().await?;
15283
15284        let mut seen = HashSet::new();
15285        let mut plans = Vec::with_capacity(epochs.len());
15286        for epoch in epochs {
15287            if !seen.insert(epoch.clone()) {
15288                continue;
15289            }
15290            let entry = self
15291                .owned_interests
15292                .iter()
15293                .find(|entry| {
15294                    entry.epoch.as_ref() == Some(epoch)
15295                        && entry.state == SubscriberOwnerState::Staged
15296                })
15297                .ok_or(SubscriberOwnerError::NotStaged)?;
15298            let position = entry
15299                .progress
15300                .as_ref()
15301                .map(|progress| &progress.through)
15302                .or(entry.baseline.as_ref())
15303                .ok_or(SubscriberOwnerError::MissingBaseline)?;
15304            let baseline = position.number;
15305            if through.number < baseline {
15306                return Err(SubscriberOwnerError::ProgressRegression {
15307                    current: baseline,
15308                    target: through.number,
15309                });
15310            }
15311            let from_block = baseline
15312                .checked_add(1)
15313                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
15314            if through.number == baseline && through.hash != position.hash {
15315                return Err(SubscriberOwnerError::ProgressConflict {
15316                    number: baseline,
15317                    current_hash: position.hash,
15318                    target_hash: through.hash,
15319                });
15320            }
15321            if through.number == from_block
15322                && through
15323                    .parent_hash
15324                    .is_some_and(|parent| parent != position.hash)
15325            {
15326                return Err(SubscriberOwnerError::ProgressConflict {
15327                    number: baseline,
15328                    current_hash: position.hash,
15329                    target_hash: through.parent_hash.expect("checked as present above"),
15330                });
15331            }
15332            if entry
15333                .interests
15334                .iter()
15335                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
15336            {
15337                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
15338            }
15339            plans.push(SubscriberOwnerReconcilePlan {
15340                epoch: epoch.clone(),
15341                interests: entry.interests.clone(),
15342                retained: *position,
15343                from_block,
15344            });
15345        }
15346
15347        // The ordering is intentional and part of the public continuity
15348        // contract: connect first, then fetch the bounded historical window.
15349        self.ensure_streams().await?;
15350        let provider = self.provider.clone();
15351        let filters = merged_owner_reconcile_filters(&plans, through.number);
15352        let retained = plans.iter().map(|plan| plan.retained).collect();
15353        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
15354        let fetch = fetch_owner_catchup::<P, N>(
15355            provider,
15356            filters,
15357            retained,
15358            through,
15359            SubscriberOwnerCatchupOptions {
15360                target_preverified: false,
15361                max_logs: self.config.max_pending_records,
15362                max_log_bytes: self.config.max_backfill_log_bytes,
15363                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
15364            },
15365        );
15366        let SubscriberOwnerCatchup { logs, certified } =
15367            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
15368
15369        let records = logs
15370            .into_iter()
15371            .map(|log| log_input_record(log, InputSource::Backfill))
15372            .collect();
15373        let mut routed_records = Vec::new();
15374        for record in dedupe_records(sort_records(records)).map_err(|error| {
15375            SubscriberError::InvalidBackfill(format!(
15376                "conflicting duplicate owner catch-up record: {error}"
15377            ))
15378        })? {
15379            let block_number = match &record.input {
15380                ReactiveInput::Log(log) => log
15381                    .block_number
15382                    .expect("bulk catch-up logs were validated before commit"),
15383                _ => unreachable!("bulk owner catch-up contains log records only"),
15384            };
15385            let owners: Vec<SubscriberOwnerEpoch> = plans
15386                .iter()
15387                .filter(|plan| block_number >= plan.from_block)
15388                .filter(|plan| {
15389                    plan.interests
15390                        .iter()
15391                        .any(|interest| interest_matches(interest, &record.input))
15392                })
15393                .map(|plan| plan.epoch.clone())
15394                .collect();
15395            if !owners.is_empty() {
15396                routed_records.push((record, owners));
15397            }
15398        }
15399        self.ensure_pending_record_capacity(
15400            routed_records.len(),
15401            "owner reconciliation historical records",
15402        )?;
15403
15404        // Nothing provider-derived becomes authoritative until every record is
15405        // known to fit. In particular, preserve queued retry state and owner
15406        // progress when the bounded delivery queue cannot accept the catch-up.
15407        self.pending_backfills.retain(|queued| {
15408            queued
15409                .epoch
15410                .as_ref()
15411                .is_none_or(|epoch| !target_epochs.contains(epoch))
15412        });
15413        for (record, owners) in routed_records {
15414            self.enqueue_owner_record_for_owners_unmerged(record, owners);
15415        }
15416        self.promote_reconcile_owner_records(&target_epochs);
15417        self.seed_reconciled_filter_anchors(&plans, certified.number);
15418
15419        let stream_revision = self.stream_revision;
15420        let mut progress = Vec::with_capacity(plans.len());
15421        for plan in plans {
15422            let item = SubscriberOwnerProgress {
15423                owner: plan.epoch.clone(),
15424                through: certified,
15425            };
15426            let entry = self
15427                .owned_interests
15428                .iter_mut()
15429                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
15430                .expect("bulk reconcile holds exclusive access after epoch preflight");
15431            entry.progress = Some(item.clone());
15432            entry.progress_stream_revision = Some(stream_revision);
15433            progress.push(item);
15434        }
15435        Ok(progress)
15436    }
15437
15438    async fn drive_reconcile_fetch<T, F>(
15439        &mut self,
15440        fetch: F,
15441        target_epochs: &HashSet<SubscriberOwnerEpoch>,
15442    ) -> Result<T, SubscriberOwnerError>
15443    where
15444        F: Future<Output = Result<T, SubscriberOwnerError>>,
15445    {
15446        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
15447            return fetch.await;
15448        }
15449        let mut fetch = Box::pin(fetch);
15450        loop {
15451            let event = {
15452                let live = Box::pin(self.next_event());
15453                match select(fetch, live).await {
15454                    Either::Left((result, pending_live)) => {
15455                        drop(pending_live);
15456                        return result;
15457                    }
15458                    Either::Right((event, pending_fetch)) => {
15459                        fetch = pending_fetch;
15460                        event
15461                    }
15462                }
15463            };
15464            let event = event?.ok_or_else(|| {
15465                SubscriberError::Provider(
15466                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
15467                )
15468            })?;
15469            self.buffer_reconcile_event_for_owners(&event, target_epochs);
15470            self.enqueue_event_excluding_owners(event, target_epochs);
15471            self.check_resource_error()?;
15472        }
15473    }
15474
15475    /// Poll one driver control future with priority over the next scoped batch.
15476    ///
15477    /// This is the supported control-interleaving primitive for a subscriber
15478    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
15479    /// the caller's pending control future alive. When control wins, the
15480    /// in-progress subscriber poll is cancelled at a documented safe boundary:
15481    /// queued records are removed only when a complete batch is returned,
15482    /// successful backfill steps are committed before the next await, provider
15483    /// streams created but not installed are dropped, and installed streams
15484    /// remain owned by the subscriber for the next call.
15485    ///
15486    /// The control future is polled first. Therefore a ready shutdown/removal
15487    /// command cannot starve behind a continuously ready subscriber queue.
15488    ///
15489    /// # Errors
15490    ///
15491    /// Returns [`SubscriberError`] when the subscriber poll encounters a
15492    /// transport, continuity, decoding, configuration, or resource failure.
15493    pub async fn next_scoped_batch_or<C, F>(
15494        &mut self,
15495        control: Pin<&mut F>,
15496    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
15497    where
15498        C: Send,
15499        F: Future<Output = C> + Send,
15500    {
15501        let batch = self.next_scoped_batch();
15502        match select(control, batch).await {
15503            Either::Left((control, pending_batch)) => {
15504                drop(pending_batch);
15505                Ok(SubscriberDriverPoll::Control(control))
15506            }
15507            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
15508        }
15509    }
15510
15511    /// Return the next subscriber batch while retaining staged-owner delivery
15512    /// provenance captured at enqueue time.
15513    ///
15514    /// Transaction-aware drivers must use this method. The compatibility
15515    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
15516    /// its historical behavior for existing callers.
15517    ///
15518    /// For command interleaving, prefer
15519    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
15520    /// cancellation-safety invariants of this poll and prioritizes ready control.
15521    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
15522        Box::pin(async {
15523            self.check_resource_error()?;
15524            if self.chain_id.is_none()
15525                && (!self.pending_records.is_empty()
15526                    || !self.pending_chain_controls.is_empty()
15527                    || !self.pending_backfills.is_empty()
15528                    || !self.interests.is_empty())
15529            {
15530                self.ensure_chain_id().await?;
15531            }
15532            if let Some(batch) = self.drain_next_scoped_batch() {
15533                return Ok(Some(batch));
15534            }
15535
15536            // Subscribe/adopt the complete desired topology before resolving
15537            // any queued historical upper bound. Live streams therefore own
15538            // every event that can arrive while the bounded backfill is in
15539            // flight, including the coordinated registration window.
15540            self.ensure_streams().await?;
15541            self.check_resource_error()?;
15542            if let Some(batch) = self.drain_next_scoped_batch() {
15543                return Ok(Some(batch));
15544            }
15545
15546            self.drain_pending_backfills().await?;
15547            self.check_resource_error()?;
15548            if let Some(batch) = self.drain_next_scoped_batch() {
15549                return Ok(Some(batch));
15550            }
15551
15552            if self.interests.is_empty() {
15553                return Ok(None);
15554            }
15555
15556            loop {
15557                let Some(event) = self.next_event().await? else {
15558                    return Ok(None);
15559                };
15560
15561                self.enqueue_event(event);
15562                self.check_resource_error()?;
15563                if let Some(batch) = self.drain_next_scoped_batch() {
15564                    return Ok(Some(batch));
15565                }
15566            }
15567        })
15568    }
15569
15570    /// Bring live streams in line with the current interest set.
15571    ///
15572    /// Runs incrementally: the desired-vs-live diff only happens when interest
15573    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
15574    /// steady-state polling costs nothing here. Missing sources are connected,
15575    /// sources for retired filters are dropped (dropping an Alloy subscription
15576    /// unsubscribes provider-side), and unrelated live streams — with their
15577    /// delivery and anchor state — are left untouched.
15578    ///
15579    /// A newly connected log source whose filter already has a delivery anchor
15580    /// is caught up from that anchor immediately after subscribing (the same
15581    /// subscribe-then-backfill order the reconnect path uses). Together with
15582    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
15583    /// window between an adoption backfill and live stream start.
15584    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
15585        if !self.sources_dirty {
15586            return Ok(());
15587        }
15588        // An interest-less subscriber never touches the provider
15589        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
15590        // the empty desired topology as clean so a deliberately empty staged
15591        // epoch can reconcile and activate instead of remaining dirty forever.
15592        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
15593            self.bump_stream_revision();
15594            self.sources_dirty = false;
15595            return Ok(());
15596        }
15597
15598        let desired = self.stream_sources()?;
15599        let missing: Vec<SubscriberStreamSource> = match &self.state {
15600            AlloySubscriberState::Active(streams) => desired
15601                .iter()
15602                .filter(|source| !streams.contains_source(source))
15603                .cloned()
15604                .collect(),
15605            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
15606        };
15607
15608        for source in missing {
15609            let stream = match self.connect_source_stream(source.clone()).await {
15610                Ok(stream) => stream,
15611                Err(error)
15612                    if source.is_flashblocks()
15613                        && self.config.preconfirmations == PreconfirmationMode::Preferred =>
15614                {
15615                    tracing::warn!(
15616                        stream = source.label(),
15617                        error = %error,
15618                        "Flashblocks source unavailable; canonical delivery remains active"
15619                    );
15620                    if self.config.reconnect.enabled {
15621                        self.schedule_flashblock_reconnect(
15622                            source,
15623                            self.config.reconnect.retry_delay,
15624                        );
15625                    }
15626                    continue;
15627                }
15628                Err(error) => return Err(error),
15629            };
15630            // Publish each successful connection before any later await. If a
15631            // second connection or anchored catch-up fails/cancels, this stream
15632            // remains live and the next reconcile skips reconnecting it.
15633            self.install_source_stream(source.clone(), stream);
15634            if self.source_requires_backfill(&source) {
15635                self.queue_source_backfill(source);
15636            }
15637        }
15638
15639        while let Some(source) = self.pending_source_backfills.front().cloned() {
15640            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
15641                && matches!(
15642                    &self.state,
15643                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
15644                );
15645            if !desired_and_live {
15646                self.pending_source_backfills.pop_front();
15647                continue;
15648            }
15649
15650            // Anchored catch-up for a source with a known delivery watermark
15651            // (seeded by a drained adoption backfill, or inherited from a
15652            // filter shape that was live before): subscribe first, then fetch
15653            // the gap, so nothing lands between the two. Pop only after the
15654            // request succeeds; errors and cancellation retain retry intent.
15655            let event = self.backfill_reconnected_source(&source).await?;
15656            self.pending_source_backfills.pop_front();
15657            if let Some(event) = event {
15658                self.enqueue_event(event);
15659            }
15660        }
15661
15662        if let AlloySubscriberState::Active(streams) = &mut self.state {
15663            streams.retain_sources(&desired);
15664            if streams.is_empty() {
15665                self.state = AlloySubscriberState::Empty;
15666            }
15667        }
15668
15669        self.bump_stream_revision();
15670        self.sources_dirty = false;
15671        self.retire_unreferenced_filters();
15672        Ok(())
15673    }
15674
15675    fn install_source_stream(
15676        &mut self,
15677        source: SubscriberStreamSource,
15678        stream: BoxStream<'static, SubscriberEvent<N>>,
15679    ) {
15680        match &mut self.state {
15681            AlloySubscriberState::Active(streams) => {
15682                if streams.contains_source(&source) {
15683                    return;
15684                }
15685                streams.push(source, stream);
15686            }
15687            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
15688                let mut streams = SubscriberStreams::new();
15689                streams.push(source, stream);
15690                self.state = AlloySubscriberState::Active(streams);
15691            }
15692        }
15693        // A partially completed reconcile is still a topology change. Advance
15694        // the revision now rather than only at the final clean boundary.
15695        self.bump_stream_revision();
15696    }
15697
15698    fn schedule_flashblock_reconnect(
15699        &mut self,
15700        source: SubscriberStreamSource,
15701        first_delay: Duration,
15702    ) {
15703        if self
15704            .pending_flashblock_reconnect_sources
15705            .iter()
15706            .any(|pending| pending.same_key(&source))
15707        {
15708            return;
15709        }
15710        self.pending_flashblock_reconnect_sources
15711            .push(source.clone());
15712        self.pending_flashblock_reconnects
15713            .push(flashblock_reconnect_future(
15714                self.provider.root().clone(),
15715                source,
15716                self.config.max_batch_size,
15717                self.config.reconnect.clone(),
15718                first_delay,
15719                self.config.flashblock_poll_interval,
15720            ));
15721    }
15722
15723    fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
15724        if !self.config.reconnect.enabled {
15725            return;
15726        }
15727        self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
15728    }
15729
15730    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
15731        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
15732            if self.last_seen_log_blocks.contains_key(id))
15733    }
15734
15735    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
15736        if !self
15737            .pending_source_backfills
15738            .iter()
15739            .any(|pending| pending.same_key(&source))
15740        {
15741            self.pending_source_backfills.push_back(source);
15742        }
15743    }
15744
15745    /// Fetch queued adoption/continuity backfills, oldest first.
15746    ///
15747    /// An entry is consumed only after its `get_logs` fetch succeeds — a
15748    /// transient RPC failure surfaces the error and leaves the entry queued for
15749    /// the next poll, so a flaky request cannot silently discard the missed
15750    /// window the backfill exists to close. Open-ended backfills resolve their
15751    /// upper bound to the provider's current head before fetching, and every
15752    /// drained backfill advances the filter's delivery anchor to that bound —
15753    /// even a zero-log window — so the filter is reconnect-protected from then
15754    /// on. Draining pauses as soon as records are ready for delivery; remaining
15755    /// entries stay queued.
15756    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
15757        while let Some(queued) = self.pending_backfills.front() {
15758            // Owner was removed while its backfill was queued.
15759            let epoch = queued.epoch.clone();
15760            let owner = queued.owner.clone();
15761            let owner_exists = match (&epoch, &owner) {
15762                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
15763                (None, Some(owner)) => self.owner_interests(owner).is_some(),
15764                (None, None) => true,
15765            };
15766            if !owner_exists {
15767                self.pending_backfills.pop_front();
15768                continue;
15769            }
15770            let filters = queued.filters.clone();
15771            let backfill = queued.backfill;
15772
15773            let to_block = match backfill.end_block() {
15774                Some(to_block) => to_block,
15775                None => self
15776                    .provider
15777                    .get_block_number()
15778                    .await
15779                    .map_err(provider_error)?,
15780            };
15781            if to_block < backfill.start_block() {
15782                // An exclusive post-baseline range can be empty when the
15783                // provider is still exactly at the retained head. Consume the
15784                // work only after validating that head and seed the filter at
15785                // the proven baseline so reconnect catch-up starts at C + 1.
15786                let certified = if let Some(retained) = backfill.retained_anchor() {
15787                    let actual =
15788                        fetch_provider_block_ref::<P, N>(&self.provider, retained.number).await?;
15789                    if !block_ref_satisfies_expected(&actual, retained) {
15790                        return Err(SubscriberError::InvalidBackfill(format!(
15791                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
15792                            retained.number, retained.hash, actual.number, actual.hash
15793                        )));
15794                    }
15795                    if to_block < retained.number {
15796                        return Err(SubscriberError::InvalidBackfill(format!(
15797                            "backfill upper bound {to_block} precedes retained anchor {}",
15798                            retained.number
15799                        )));
15800                    }
15801                    Some(actual)
15802                } else {
15803                    None
15804                };
15805                self.pending_backfills.pop_front();
15806                for filter in &filters {
15807                    let source_id = self.log_source_id(filter);
15808                    if let Some(certified) = certified {
15809                        self.last_seen_log_blocks
15810                            .entry(source_id)
15811                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
15812                            .or_insert(certified.number);
15813                    }
15814                }
15815                if owner.is_none()
15816                    && let Some(certified) = certified
15817                {
15818                    self.pending_chain_controls
15819                        .push_back(global_backfill_barrier(backfill, certified));
15820                }
15821                if !self.pending_chain_controls.is_empty() {
15822                    break;
15823                }
15824                continue;
15825            }
15826
15827            let through = fetch_provider_block_ref::<P, N>(&self.provider, to_block).await?;
15828            let request_filters =
15829                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
15830            let retained = backfill.retained_anchor().copied().into_iter().collect();
15831            let SubscriberOwnerCatchup {
15832                mut logs,
15833                certified,
15834            } = fetch_owner_catchup::<&P, N>(
15835                &self.provider,
15836                request_filters,
15837                retained,
15838                through,
15839                SubscriberOwnerCatchupOptions {
15840                    target_preverified: true,
15841                    max_logs: self.config.max_pending_records,
15842                    max_log_bytes: self.config.max_backfill_log_bytes,
15843                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
15844                },
15845            )
15846            .await
15847            .map_err(lazy_backfill_error)?;
15848            logs.sort_by_key(|log| {
15849                (
15850                    log.block_number.unwrap_or_default(),
15851                    log.transaction_index.unwrap_or_default(),
15852                    log.log_index.unwrap_or_default(),
15853                )
15854            });
15855            logs.dedup();
15856            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
15857
15858            // Fetch succeeded: consume the entry, deliver, and advance the
15859            // complete filter group through one globally ordered window.
15860            self.pending_backfills.pop_front();
15861            if let Some(epoch) = epoch.as_ref() {
15862                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
15863            } else if let Some(owner) = owner.as_ref() {
15864                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
15865            } else {
15866                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
15867                self.pending_chain_controls
15868                    .push_back(global_backfill_barrier(backfill, certified));
15869            }
15870            for filter in &filters {
15871                let source_id = self.log_source_id(filter);
15872                let anchor = self
15873                    .last_seen_log_blocks
15874                    .entry(source_id)
15875                    .or_insert(certified.number);
15876                *anchor = (*anchor).max(certified.number);
15877            }
15878
15879            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
15880                break;
15881            }
15882        }
15883        Ok(())
15884    }
15885
15886    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
15887        let sources = match resolve_subscriber_transport(self.mode)? {
15888            SubscriberTransport::PubSub => self.pubsub_stream_sources(),
15889            SubscriberTransport::Polling => self.polling_stream_sources(),
15890        };
15891        #[cfg(feature = "raw-flashblocks-json")]
15892        let sources = {
15893            let mut sources = sources;
15894            if self.external_flashblock_update_channel_opened {
15895                sources.push(SubscriberStreamSource::ExternalFlashblockUpdates);
15896            }
15897            sources
15898        };
15899        Ok(sources)
15900    }
15901
15902    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
15903        let mut sources = Vec::new();
15904        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
15905
15906        for filter in self.log_stream_filters() {
15907            let id = self.log_source_id(&filter);
15908            if let Some(anchor) = inherited_anchor {
15909                self.last_seen_log_blocks.entry(id).or_insert(anchor);
15910            }
15911            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
15912        }
15913
15914        if needs_pending_hash_stream(&self.interests) {
15915            sources.push(SubscriberStreamSource::PubSubPendingHashes);
15916        }
15917
15918        if needs_header_block_stream(&self.interests) {
15919            if !self.uses_external_flashblock_updates()
15920                && self.config.preconfirmations != PreconfirmationMode::Disabled
15921                && self.chain_id.and_then(flashblocks_adapter).is_some()
15922            {
15923                sources.push(SubscriberStreamSource::CanonicalHeadPolling);
15924            } else {
15925                sources.push(SubscriberStreamSource::PubSubBlockHeaders);
15926            }
15927        }
15928
15929        if self.config.preconfirmations != PreconfirmationMode::Disabled
15930            && !self.uses_external_flashblock_updates()
15931        {
15932            match self.chain_id.and_then(flashblocks_adapter) {
15933                Some(FlashblocksAdapter::NativeSubscriptions) => {
15934                    sources.push(SubscriberStreamSource::BaseFlashblocks);
15935                    for filter in self.log_stream_filters() {
15936                        let id = self.log_source_id(&filter);
15937                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
15938                    }
15939                }
15940                Some(FlashblocksAdapter::PendingStatePolling) => {
15941                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15942                }
15943                None => {}
15944            }
15945        }
15946
15947        sources
15948    }
15949
15950    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
15951        let mut sources = Vec::new();
15952
15953        for filter in self.log_stream_filters() {
15954            sources.push(SubscriberStreamSource::PollingLog { filter });
15955        }
15956
15957        if needs_pending_hash_stream(&self.interests) {
15958            sources.push(SubscriberStreamSource::PollingPendingHashes);
15959        }
15960
15961        if self.config.preconfirmations != PreconfirmationMode::Disabled
15962            && !self.uses_external_flashblock_updates()
15963            && self.chain_id.and_then(flashblocks_adapter)
15964                == Some(FlashblocksAdapter::PendingStatePolling)
15965        {
15966            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15967        }
15968
15969        sources
15970    }
15971
15972    fn log_source_id(&mut self, filter: &Filter) -> usize {
15973        if let Some(id) = self.log_source_ids.get(filter) {
15974            return *id;
15975        }
15976
15977        let id = self.next_log_source_id;
15978        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
15979        self.log_source_ids.insert(filter.clone(), id);
15980        id
15981    }
15982
15983    async fn connect_source_stream(
15984        &mut self,
15985        source: SubscriberStreamSource,
15986    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15987        match source {
15988            SubscriberStreamSource::PubSubLog { id, filter } => {
15989                self.connect_pubsub_log_stream(id, filter).await
15990            }
15991            SubscriberStreamSource::BasePendingLog { id, filter } => {
15992                self.connect_base_pending_log_stream(id, filter).await
15993            }
15994            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
15995            SubscriberStreamSource::OpPendingFlashblocks => {
15996                self.connect_op_flashblock_tick_stream()
15997            }
15998            SubscriberStreamSource::CanonicalHeadPolling => {
15999                self.connect_canonical_head_tick_stream()
16000            }
16001            SubscriberStreamSource::PubSubPendingHashes => {
16002                self.connect_pubsub_pending_hash_stream().await
16003            }
16004            SubscriberStreamSource::PubSubBlockHeaders => {
16005                self.connect_pubsub_block_header_stream().await
16006            }
16007            SubscriberStreamSource::PollingLog { filter } => {
16008                self.connect_polling_log_stream(filter).await
16009            }
16010            SubscriberStreamSource::PollingPendingHashes => {
16011                self.connect_polling_pending_hash_stream().await
16012            }
16013            #[cfg(feature = "raw-flashblocks-json")]
16014            SubscriberStreamSource::ExternalFlashblockUpdates => {
16015                let receiver = self.external_flashblock_updates.take().ok_or_else(|| {
16016                    SubscriberError::Provider(
16017                        "external Flashblock update channel receiver is unavailable".into(),
16018                    )
16019                })?;
16020                let updates = stream::unfold(receiver, |mut receiver| async move {
16021                    receiver
16022                        .recv()
16023                        .await
16024                        .map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver))
16025                });
16026                Ok(stream_with_termination(
16027                    updates,
16028                    SubscriberStreamSource::ExternalFlashblockUpdates,
16029                ))
16030            }
16031        }
16032    }
16033
16034    async fn connect_pubsub_log_stream(
16035        &mut self,
16036        id: usize,
16037        filter: Filter,
16038    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16039        #[cfg(feature = "reactive-ws")]
16040        {
16041            let source = SubscriberStreamSource::PubSubLog {
16042                id,
16043                filter: filter.clone(),
16044            };
16045            let stream = self
16046                .provider
16047                .subscribe_logs(&filter)
16048                .channel_size(self.config.max_batch_size.max(1))
16049                .await
16050                .map_err(provider_error)?
16051                .into_stream()
16052                .map(move |log| SubscriberEvent::Log { source_id: id, log });
16053            Ok(stream_with_termination(stream, source))
16054        }
16055
16056        #[cfg(not(feature = "reactive-ws"))]
16057        {
16058            let _ = (id, filter);
16059            Err(SubscriberError::Unsupported(
16060                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16061            ))
16062        }
16063    }
16064
16065    async fn connect_base_pending_log_stream(
16066        &mut self,
16067        id: usize,
16068        filter: Filter,
16069    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16070        #[cfg(feature = "reactive-ws")]
16071        {
16072            let source = SubscriberStreamSource::BasePendingLog {
16073                id,
16074                filter: filter.clone(),
16075            };
16076            let params = base_pending_log_filter(&filter)?;
16077            let stream = self
16078                .provider
16079                .subscribe::<_, Log>(("pendingLogs", params))
16080                .channel_size(self.config.max_batch_size.max(1))
16081                .await
16082                .map_err(provider_error)?
16083                .into_stream()
16084                .map(move |log| SubscriberEvent::BasePendingLogTimed {
16085                    source_id: id,
16086                    log,
16087                    timing: FlashblockIngressTiming::new(Instant::now()),
16088                });
16089            Ok(stream_with_termination(stream, source))
16090        }
16091
16092        #[cfg(not(feature = "reactive-ws"))]
16093        {
16094            let _ = (id, filter);
16095            Err(SubscriberError::Unsupported(
16096                "Base Flashblocks require the reactive-ws feature",
16097            ))
16098        }
16099    }
16100
16101    async fn connect_base_flashblock_stream(
16102        &mut self,
16103    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16104        #[cfg(feature = "reactive-ws")]
16105        {
16106            let stream = self
16107                .provider
16108                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
16109                .channel_size(self.config.max_batch_size.max(1))
16110                .await
16111                .map_err(provider_error)?
16112                .into_stream()
16113                .map(|payload| SubscriberEvent::BaseFlashblockTimed {
16114                    payload,
16115                    timing: FlashblockIngressTiming::new(Instant::now()),
16116                });
16117            Ok(stream_with_termination(
16118                stream,
16119                SubscriberStreamSource::BaseFlashblocks,
16120            ))
16121        }
16122
16123        #[cfg(not(feature = "reactive-ws"))]
16124        {
16125            Err(SubscriberError::Unsupported(
16126                "Base Flashblocks require the reactive-ws feature",
16127            ))
16128        }
16129    }
16130
16131    fn connect_canonical_head_tick_stream(
16132        &self,
16133    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16134        let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
16135        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
16136        let stream = stream::unfold(interval, |mut interval| async move {
16137            interval.tick().await;
16138            Some((SubscriberEvent::CanonicalHeadTick, interval))
16139        });
16140        Ok(stream_with_termination(
16141            stream,
16142            SubscriberStreamSource::CanonicalHeadPolling,
16143        ))
16144    }
16145
16146    fn connect_op_flashblock_tick_stream(
16147        &self,
16148    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16149        let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
16150        let mut interval =
16151            tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
16152        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
16153        let stream = stream::unfold(interval, |mut interval| async move {
16154            interval.tick().await;
16155            Some((
16156                SubscriberEvent::OpFlashblockTickTimed(
16157                    FlashblockIngressTiming::new(Instant::now()),
16158                ),
16159                interval,
16160            ))
16161        });
16162        Ok(stream_with_termination(
16163            stream,
16164            SubscriberStreamSource::OpPendingFlashblocks,
16165        ))
16166    }
16167
16168    async fn connect_pubsub_pending_hash_stream(
16169        &mut self,
16170    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16171        #[cfg(feature = "reactive-ws")]
16172        {
16173            let stream = self
16174                .provider
16175                .subscribe_pending_transactions()
16176                .channel_size(self.config.max_batch_size.max(1))
16177                .await
16178                .map_err(provider_error)?
16179                .into_stream()
16180                .map(SubscriberEvent::PendingHash);
16181            Ok(stream_with_termination(
16182                stream,
16183                SubscriberStreamSource::PubSubPendingHashes,
16184            ))
16185        }
16186
16187        #[cfg(not(feature = "reactive-ws"))]
16188        {
16189            Err(SubscriberError::Unsupported(
16190                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16191            ))
16192        }
16193    }
16194
16195    async fn connect_pubsub_block_header_stream(
16196        &mut self,
16197    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16198        #[cfg(feature = "reactive-ws")]
16199        {
16200            let stream = self
16201                .provider
16202                .subscribe_blocks()
16203                .channel_size(self.config.max_batch_size.max(1))
16204                .await
16205                .map_err(provider_error)?
16206                .into_stream()
16207                .map(SubscriberEvent::BlockHeader);
16208            Ok(stream_with_termination(
16209                stream,
16210                SubscriberStreamSource::PubSubBlockHeaders,
16211            ))
16212        }
16213
16214        #[cfg(not(feature = "reactive-ws"))]
16215        {
16216            Err(SubscriberError::Unsupported(
16217                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16218            ))
16219        }
16220    }
16221
16222    async fn connect_polling_log_stream(
16223        &mut self,
16224        filter: Filter,
16225    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16226        #[cfg(feature = "reactive-polling")]
16227        {
16228            let source = SubscriberStreamSource::PollingLog {
16229                filter: filter.clone(),
16230            };
16231            let stream = self
16232                .provider
16233                .watch_logs(&filter)
16234                .await
16235                .map_err(provider_error)?
16236                .with_channel_size(self.config.max_batch_size.max(1))
16237                .into_stream()
16238                .map(SubscriberEvent::Logs);
16239            Ok(stream_with_termination(stream, source))
16240        }
16241
16242        #[cfg(not(feature = "reactive-polling"))]
16243        {
16244            let _ = filter;
16245            Err(SubscriberError::Unsupported(
16246                "AlloySubscriber polling mode requires the reactive-polling feature",
16247            ))
16248        }
16249    }
16250
16251    async fn connect_polling_pending_hash_stream(
16252        &mut self,
16253    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16254        #[cfg(feature = "reactive-polling")]
16255        {
16256            let stream = self
16257                .provider
16258                .watch_pending_transactions()
16259                .await
16260                .map_err(provider_error)?
16261                .with_channel_size(self.config.max_batch_size.max(1))
16262                .into_stream()
16263                .map(SubscriberEvent::PendingHashes);
16264            Ok(stream_with_termination(
16265                stream,
16266                SubscriberStreamSource::PollingPendingHashes,
16267            ))
16268        }
16269
16270        #[cfg(not(feature = "reactive-polling"))]
16271        {
16272            Err(SubscriberError::Unsupported(
16273                "AlloySubscriber polling mode requires the reactive-polling feature",
16274            ))
16275        }
16276    }
16277
16278    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16279        loop {
16280            let ready = match &mut self.state {
16281                AlloySubscriberState::Active(streams)
16282                    if !self.pending_flashblock_reconnects.is_empty() =>
16283                {
16284                    let stream_event = Box::pin(streams.next());
16285                    let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
16286                    match select(reconnect, stream_event).await {
16287                        Either::Left((reconnect, pending_event)) => {
16288                            drop(pending_event);
16289                            let Some((source, result)) = reconnect else {
16290                                continue;
16291                            };
16292                            SubscriberReady::FlashblockReconnect(source, result)
16293                        }
16294                        Either::Right((event, pending_reconnect)) => {
16295                            drop(pending_reconnect);
16296                            SubscriberReady::Event(event)
16297                        }
16298                    }
16299                }
16300                AlloySubscriberState::Active(streams) => {
16301                    SubscriberReady::Event(streams.next().await)
16302                }
16303                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
16304                    if !self.pending_flashblock_reconnects.is_empty() =>
16305                {
16306                    let Some((source, result)) = self.pending_flashblock_reconnects.next().await
16307                    else {
16308                        continue;
16309                    };
16310                    SubscriberReady::FlashblockReconnect(source, result)
16311                }
16312                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
16313                    return Ok(None);
16314                }
16315            };
16316
16317            let event = match ready {
16318                SubscriberReady::Event(event) => event,
16319                SubscriberReady::FlashblockReconnect(source, result) => {
16320                    self.pending_flashblock_reconnect_sources
16321                        .retain(|pending| !pending.same_key(&source));
16322                    match result {
16323                        Ok(stream) => {
16324                            self.install_source_stream(source, stream);
16325                        }
16326                        Err(error)
16327                            if self.config.preconfirmations == PreconfirmationMode::Preferred =>
16328                        {
16329                            tracing::warn!(
16330                                stream = source.label(),
16331                                error = %error,
16332                                "Flashblocks reconnect window exhausted; canonical delivery remains active"
16333                            );
16334                            self.reschedule_preferred_flashblock(source);
16335                        }
16336                        Err(error) => return Err(error),
16337                    }
16338                    continue;
16339                }
16340            };
16341
16342            let Some(event) = event else {
16343                return Err(SubscriberError::Provider(
16344                    "Alloy subscriber streams terminated before the subscriber was stopped"
16345                        .to_owned(),
16346                ));
16347            };
16348
16349            match event {
16350                SubscriberEvent::StreamTerminated(source) => {
16351                    if source.is_external_flashblocks() {
16352                        #[cfg(feature = "raw-flashblocks-json")]
16353                        {
16354                            self.external_flashblock_update_channel_opened = false;
16355                        }
16356                        if let AlloySubscriberState::Active(streams) = &mut self.state {
16357                            streams
16358                                .entries
16359                                .retain(|entry| !entry.source.is_external_flashblocks());
16360                            streams.normalize_next_index();
16361                        }
16362                        self.invalidate_preconfirmation_snapshot();
16363                        if self.config.preconfirmations == PreconfirmationMode::Required {
16364                            return Err(SubscriberError::Provider(
16365                                "required external Flashblock update channel closed".into(),
16366                            ));
16367                        }
16368                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16369                    }
16370                    // Persist the missing-source intent before the first await.
16371                    // If a control command cancels this poll during reconnect,
16372                    // the next poll will reconcile the desired/live diff.
16373                    if source.is_flashblocks() {
16374                        self.invalidate_flashblock_generation();
16375                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16376                    }
16377                    self.sources_dirty = true;
16378                    self.bump_stream_revision();
16379                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
16380                        self.sources_dirty = false;
16381                        if let Some(backfill_event) =
16382                            self.normalize_flashblock_event(backfill_event).await?
16383                        {
16384                            self.verify_event_log_blocks(&backfill_event).await?;
16385                            return Ok(Some(backfill_event));
16386                        }
16387                    }
16388                    self.sources_dirty = false;
16389                }
16390                event => {
16391                    let Some(event) = self.normalize_flashblock_event(event).await? else {
16392                        continue;
16393                    };
16394                    self.verify_event_log_blocks(&event).await?;
16395                    return Ok(Some(event));
16396                }
16397            }
16398        }
16399    }
16400
16401    fn invalidate_flashblock_generation(&mut self) {
16402        self.pending_records
16403            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
16404        self.pending_preconfirmation_invalidation = true;
16405        self.reset_flashblock_tracking();
16406        if let Some(provider) = self.provider_ref.as_mut() {
16407            provider.generation = provider.generation.saturating_add(1);
16408        }
16409        if let AlloySubscriberState::Active(streams) = &mut self.state {
16410            streams
16411                .entries
16412                .retain(|entry| !entry.source.is_flashblocks());
16413            streams.normalize_next_index();
16414        }
16415        let reconnect_sources = self
16416            .stream_sources()
16417            .unwrap_or_default()
16418            .into_iter()
16419            .filter(SubscriberStreamSource::is_flashblocks)
16420            .collect::<Vec<_>>();
16421        self.pending_flashblock_reconnects.clear();
16422        self.pending_flashblock_reconnect_sources.clear();
16423        if self.config.preconfirmations == PreconfirmationMode::Required
16424            || self.config.reconnect.enabled
16425        {
16426            for source in reconnect_sources {
16427                self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
16428            }
16429        }
16430        self.sources_dirty = false;
16431        self.bump_stream_revision();
16432    }
16433
16434    async fn normalize_flashblock_event(
16435        &mut self,
16436        event: SubscriberEvent<N>,
16437    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16438        let event = match event {
16439            SubscriberEvent::BasePendingLog { source_id, log } => {
16440                SubscriberEvent::BasePendingLogTimed {
16441                    source_id,
16442                    log,
16443                    timing: FlashblockIngressTiming::new(Instant::now()),
16444                }
16445            }
16446            SubscriberEvent::BaseFlashblock(payload) => SubscriberEvent::BaseFlashblockTimed {
16447                payload,
16448                timing: FlashblockIngressTiming::new(Instant::now()),
16449            },
16450            SubscriberEvent::OpFlashblockTick => {
16451                SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(Instant::now()))
16452            }
16453            event => event,
16454        };
16455        match event {
16456            #[cfg(feature = "raw-flashblocks-json")]
16457            SubscriberEvent::ExternalFlashblockUpdate(queued) => {
16458                let provider = queued.update.provider().clone();
16459                match self.ingest_flashblock_update_with_ingress(queued.update, queued.timing) {
16460                    Ok(()) => {
16461                        let _ = queued.acknowledgement.send(Ok(()));
16462                        Ok(Some(SubscriberEvent::FlashblockObserved))
16463                    }
16464                    Err(error)
16465                        if self.config.preconfirmations == PreconfirmationMode::Preferred =>
16466                    {
16467                        let recoverable_capacity =
16468                            matches!(error, SubscriberError::ResourceExhausted(_));
16469                        if !recoverable_capacity
16470                            && let Some(configured) = self.external_flashblocks_provider.as_mut()
16471                            && configured.endpoint == provider.endpoint
16472                        {
16473                            self.rejected_external_flashblock_generation = Some(
16474                                self.rejected_external_flashblock_generation
16475                                    .map_or(provider.generation, |rejected| {
16476                                        rejected.max(provider.generation)
16477                                    }),
16478                            );
16479                            configured.generation = configured
16480                                .generation
16481                                .max(provider.generation.saturating_add(1));
16482                        }
16483                        self.invalidate_preconfirmation_snapshot();
16484                        self.last_external_flashblock_snapshot = None;
16485                        let _ = queued
16486                            .acknowledgement
16487                            .send(Err(FlashblockUpdateChannelError::Rejected));
16488                        tracing::warn!(
16489                            provider = %provider.endpoint,
16490                            generation = provider.generation,
16491                            error = %error,
16492                            "external Flashblock update rejected; canonical delivery remains active"
16493                        );
16494                        Ok(Some(SubscriberEvent::FlashblockInvalidated))
16495                    }
16496                    Err(error) => {
16497                        let _ = queued
16498                            .acknowledgement
16499                            .send(Err(FlashblockUpdateChannelError::Rejected));
16500                        Err(error)
16501                    }
16502                }
16503            }
16504            SubscriberEvent::BasePendingLogTimed {
16505                source_id,
16506                log,
16507                timing,
16508            } => {
16509                let block_number = log.block_number.ok_or_else(|| {
16510                    SubscriberError::Provider(
16511                        "pendingLogs item is missing its pending block number".into(),
16512                    )
16513                })?;
16514                let transaction_hash = log.transaction_hash.ok_or_else(|| {
16515                    SubscriberError::Provider(
16516                        "pendingLogs item is missing its transaction hash".into(),
16517                    )
16518                })?;
16519                let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
16520                    flashblock.block_number == block_number
16521                        && flashblock.contains_transaction(&transaction_hash)
16522                });
16523                let Some(flashblock) = matching.cloned() else {
16524                    if self
16525                        .latest_preconfirmation
16526                        .as_ref()
16527                        .is_some_and(|latest| block_number < latest.block_number)
16528                    {
16529                        return Ok(None);
16530                    }
16531                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
16532                        return Err(SubscriberError::ResourceExhausted(
16533                            "unmatched pendingLogs exceeded max_pending_records".into(),
16534                        ));
16535                    }
16536                    self.unmatched_pending_logs
16537                        .push_back((source_id, log, timing));
16538                    return Ok(None);
16539                };
16540                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
16541                Ok(Some(if logs.is_empty() {
16542                    SubscriberEvent::FlashblockObserved
16543                } else {
16544                    SubscriberEvent::PreconfirmedLogs {
16545                        flashblock,
16546                        logs,
16547                        timing,
16548                    }
16549                }))
16550            }
16551            SubscriberEvent::BaseFlashblockTimed {
16552                payload,
16553                timing: source_timing,
16554            } => {
16555                let (flashblock, recover_pending_snapshot) =
16556                    self.accept_base_flashblock(payload)?;
16557                let mut logs = Vec::new();
16558                let mut retained = VecDeque::new();
16559                let mut timing = source_timing;
16560                while let Some((source_id, log, log_timing)) =
16561                    self.unmatched_pending_logs.pop_front()
16562                {
16563                    let transaction_hash = log.transaction_hash;
16564                    if log.block_number == Some(flashblock.block_number)
16565                        && transaction_hash
16566                            .as_ref()
16567                            .is_some_and(|hash| flashblock.contains_transaction(hash))
16568                    {
16569                        let _ = source_id;
16570                        timing = timing.earliest(log_timing);
16571                        logs.push(log);
16572                    } else if log
16573                        .block_number
16574                        .is_some_and(|number| number >= flashblock.block_number)
16575                    {
16576                        retained.push_back((source_id, log, log_timing));
16577                    } else {
16578                        // A late log for an older speculative block can no
16579                        // longer be applied to the active cumulative branch.
16580                    }
16581                }
16582                self.unmatched_pending_logs = retained;
16583
16584                let indexed_recovery = recover_pending_snapshot.then(|| {
16585                    let payload_id = flashblock
16586                        .payload_id
16587                        .expect("indexed recovery carries a payload id");
16588                    let index = flashblock.index.expect("indexed recovery carries an index");
16589                    let last_diff = self
16590                        .base_flashblock_transactions
16591                        .as_ref()
16592                        .filter(|(known_payload, known_index, _, _)| {
16593                            *known_payload == payload_id && *known_index == index
16594                        })
16595                        .map(|(_, _, _, last_diff)| last_diff.clone())
16596                        .unwrap_or_default();
16597                    (payload_id, index, last_diff)
16598                });
16599                if recover_pending_snapshot {
16600                    if let Some(event) = self
16601                        .fetch_pending_flashblock_with_timing(indexed_recovery, timing)
16602                        .await
16603                        .map_err(PendingFlashblockPollError::into_subscriber)?
16604                    {
16605                        return Ok(Some(event));
16606                    }
16607                    self.invalidate_flashblock_generation();
16608                    return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16609                }
16610                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
16611                Ok(Some(if logs.is_empty() {
16612                    SubscriberEvent::FlashblockObserved
16613                } else {
16614                    SubscriberEvent::PreconfirmedLogs {
16615                        flashblock,
16616                        logs,
16617                        timing,
16618                    }
16619                }))
16620            }
16621            SubscriberEvent::OpFlashblockTickTimed(timing) => {
16622                self.poll_op_pending_flashblock(timing).await
16623            }
16624            SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await,
16625            SubscriberEvent::PreconfirmedLogs {
16626                flashblock,
16627                logs,
16628                timing,
16629            } => {
16630                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
16631                Ok(Some(if logs.is_empty() {
16632                    SubscriberEvent::FlashblockObserved
16633                } else {
16634                    SubscriberEvent::PreconfirmedLogs {
16635                        flashblock,
16636                        logs,
16637                        timing,
16638                    }
16639                }))
16640            }
16641            SubscriberEvent::FlashblockObserved => Ok(None),
16642            SubscriberEvent::BasePendingLog { .. }
16643            | SubscriberEvent::BaseFlashblock(_)
16644            | SubscriberEvent::OpFlashblockTick => unreachable!("normalized above"),
16645            event => Ok(Some(event)),
16646        }
16647    }
16648
16649    async fn fetch_certified_canonical_head(
16650        &mut self,
16651    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16652        tokio::time::timeout(
16653            self.config.canonical_head_request_timeout,
16654            self.fetch_certified_canonical_head_inner(),
16655        )
16656        .await
16657        .map_err(|_| {
16658            SubscriberError::Provider(format!(
16659                "canonical head certification timed out after {:?}",
16660                self.config.canonical_head_request_timeout
16661            ))
16662        })?
16663    }
16664
16665    async fn fetch_certified_canonical_head_inner(
16666        &mut self,
16667    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16668        if self.chain_id.and_then(flashblocks_adapter)
16669            == Some(FlashblocksAdapter::PendingStatePolling)
16670        {
16671            if !self.reserve_flashblock_rpc_methods(2) {
16672                return Ok(None);
16673            }
16674            self.flashblocks_rpc_metrics.pending_block_requests = self
16675                .flashblocks_rpc_metrics
16676                .pending_block_requests
16677                .saturating_add(1);
16678            let pending = self
16679                .fetch_op_pending_block()
16680                .await
16681                .map_err(PendingFlashblockPollError::into_subscriber)?
16682                .ok_or_else(|| {
16683                    SubscriberError::Provider(
16684                        "provider returned no OP pending block while certifying its parent".into(),
16685                    )
16686                })?;
16687            let header = self
16688                .certify_op_pending_parent(&pending)
16689                .await
16690                .map_err(PendingFlashblockPollError::into_subscriber)?;
16691            let certified = BlockRef {
16692                number: header.number(),
16693                hash: header.hash(),
16694                parent_hash: Some(header.parent_hash()),
16695                timestamp: Some(header.timestamp()),
16696            };
16697            if self.last_certified_canonical_head.as_ref() == Some(&certified) {
16698                return Ok(None);
16699            }
16700            self.last_certified_canonical_head = Some(certified);
16701            return Ok(Some(SubscriberEvent::BlockHeader(header)));
16702        }
16703        self.flashblocks_rpc_metrics.canonical_head_requests = self
16704            .flashblocks_rpc_metrics
16705            .canonical_head_requests
16706            .saturating_add(1);
16707        let block = self
16708            .provider
16709            .get_block_by_number(BlockNumberOrTag::Latest)
16710            .await
16711            .map_err(provider_error)?
16712            .ok_or_else(|| {
16713                SubscriberError::Provider(
16714                    "provider returned no latest block while certifying canonical head".into(),
16715                )
16716            })?;
16717        let header = block.header();
16718        if header.hash().is_zero() {
16719            return Err(SubscriberError::Provider(
16720                "provider returned a placeholder hash for the latest canonical head".into(),
16721            ));
16722        }
16723        let certified = BlockRef {
16724            number: header.number(),
16725            hash: header.hash(),
16726            parent_hash: Some(header.parent_hash()),
16727            timestamp: Some(header.timestamp()),
16728        };
16729        if self.last_certified_canonical_head.as_ref() == Some(&certified) {
16730            return Ok(None);
16731        }
16732        self.last_certified_canonical_head = Some(certified);
16733        Ok(Some(SubscriberEvent::BlockHeader(header.clone())))
16734    }
16735
16736    fn accept_base_flashblock(
16737        &mut self,
16738        payload: BaseFlashblockWirePayload,
16739    ) -> Result<(FlashblockRef, bool), SubscriberError> {
16740        let provider = self.provider_ref.clone().ok_or({
16741            SubscriberError::InvalidConfig(
16742                "Flashblocks require a stable provider ref from a pinned provider lease",
16743            )
16744        })?;
16745
16746        let (flashblock, recover_pending_snapshot) = match payload {
16747            BaseFlashblockWirePayload::Indexed(payload) => {
16748                if payload.index == 0 {
16749                    let base = payload.base.clone().ok_or_else(|| {
16750                        SubscriberError::Provider(
16751                            "indexed newFlashblocks item zero omitted its base header".into(),
16752                        )
16753                    })?;
16754                    self.base_flashblock_header = Some((payload.payload_id, base));
16755                }
16756
16757                let base = self
16758                    .base_flashblock_header
16759                    .as_ref()
16760                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
16761                    .map(|(_, base)| base);
16762                let block_number = base.map(|base| base.block_number).or_else(|| {
16763                    payload
16764                        .metadata
16765                        .as_ref()
16766                        .map(|metadata| metadata.block_number)
16767                });
16768                let block_number = block_number.ok_or_else(|| {
16769                    SubscriberError::Provider(
16770                        "indexed newFlashblocks payload omitted both base and metadata block number"
16771                            .into(),
16772                    )
16773                })?;
16774                let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
16775                let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
16776                    Some((known_payload, known_index, transactions, last_diff))
16777                        if *known_payload == payload.payload_id =>
16778                    {
16779                        if payload.index < *known_index {
16780                            return self
16781                                .latest_preconfirmation
16782                                .clone()
16783                                .map(|flashblock| (flashblock, false))
16784                                .ok_or_else(|| {
16785                                    SubscriberError::Provider(
16786                                        "regressive indexed Flashblock arrived without an active snapshot"
16787                                            .into(),
16788                                    )
16789                                });
16790                        }
16791                        if payload.index == *known_index {
16792                            if *last_diff != diff_transactions {
16793                                return Err(SubscriberError::Provider(
16794                                    "conflicting duplicate indexed Flashblock payload".into(),
16795                                ));
16796                            }
16797                        } else {
16798                            if diff_transactions
16799                                .iter()
16800                                .any(|hash| transactions.contains(hash))
16801                            {
16802                                return Err(SubscriberError::Provider(
16803                                    "indexed Flashblock repeated a transaction from an earlier diff"
16804                                        .into(),
16805                                ));
16806                            }
16807                            transactions.extend(diff_transactions.iter().copied());
16808                            *known_index = payload.index;
16809                            *last_diff = diff_transactions;
16810                        }
16811                        transactions.clone()
16812                    }
16813                    _ => {
16814                        self.base_flashblock_transactions = Some((
16815                            payload.payload_id,
16816                            payload.index,
16817                            diff_transactions.clone(),
16818                            diff_transactions.clone(),
16819                        ));
16820                        diff_transactions
16821                    }
16822                };
16823                let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
16824                let transactions_root = payload
16825                    .diff
16826                    .transactions_root
16827                    .and_then(non_placeholder_hash);
16828                let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
16829                let state_root = non_placeholder_hash(payload.diff.state_root);
16830                let timestamp = base.map(|base| base.timestamp);
16831                let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
16832                let beneficiary = base.and_then(|base| base.beneficiary);
16833                let prevrandao = base
16834                    .and_then(|base| base.prevrandao)
16835                    .and_then(non_placeholder_hash);
16836                let gas_limit = base.and_then(|base| base.gas_limit);
16837                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16838                    provider: &provider,
16839                    payload_id: Some(payload.payload_id),
16840                    index: Some(payload.index),
16841                    block_number,
16842                    partial_block_hash,
16843                    parent_hash,
16844                    state_root,
16845                    transactions_root,
16846                    transaction_hashes: &transaction_hashes,
16847                    timestamp,
16848                    base_fee_per_gas,
16849                    beneficiary,
16850                    prevrandao,
16851                    gas_limit,
16852                });
16853                let flashblock = FlashblockRef {
16854                    provider,
16855                    payload_id: Some(payload.payload_id),
16856                    index: Some(payload.index),
16857                    block_number,
16858                    content_hash,
16859                    partial_block_hash,
16860                    parent_hash,
16861                    state_root,
16862                    transactions_root,
16863                    transaction_hashes,
16864                    timestamp,
16865                    base_fee_per_gas,
16866                    beneficiary,
16867                    prevrandao,
16868                    gas_limit,
16869                };
16870                if let Some(previous) = self.latest_preconfirmation.as_ref()
16871                    && previous.same_payload(&flashblock)
16872                    && previous.index == flashblock.index
16873                    && previous.content_hash != flashblock.content_hash
16874                {
16875                    return Err(SubscriberError::Provider(
16876                        "conflicting duplicate indexed Flashblock content".into(),
16877                    ));
16878                }
16879                let recover = match self.latest_preconfirmation.as_ref() {
16880                    Some(previous) if previous.same_payload(&flashblock) => {
16881                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
16882                        {
16883                            if current < previous {
16884                                return Ok((flashblock, false));
16885                            }
16886                            current > previous.saturating_add(1)
16887                        } else {
16888                            false
16889                        }
16890                    }
16891                    Some(_) => payload.index != 0,
16892                    None => payload.index != 0,
16893                };
16894                (flashblock, recover)
16895            }
16896            BaseFlashblockWirePayload::Block(payload) => {
16897                let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
16898                let parent_hash = non_placeholder_hash(payload.parent_hash);
16899                let state_root = non_placeholder_hash(payload.state_root);
16900                let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
16901                let partial_block_hash = non_placeholder_hash(payload.hash);
16902                let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
16903                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16904                    provider: &provider,
16905                    payload_id: None,
16906                    index: None,
16907                    block_number: payload.number,
16908                    partial_block_hash,
16909                    parent_hash,
16910                    state_root,
16911                    transactions_root,
16912                    transaction_hashes: &transaction_hashes,
16913                    timestamp: Some(payload.timestamp),
16914                    base_fee_per_gas: payload.base_fee_per_gas,
16915                    beneficiary: payload.miner,
16916                    prevrandao,
16917                    gas_limit: payload.gas_limit,
16918                });
16919                let flashblock = FlashblockRef {
16920                    provider,
16921                    payload_id: None,
16922                    index: None,
16923                    block_number: payload.number,
16924                    content_hash,
16925                    partial_block_hash,
16926                    parent_hash,
16927                    state_root,
16928                    transactions_root,
16929                    transaction_hashes,
16930                    timestamp: Some(payload.timestamp),
16931                    base_fee_per_gas: payload.base_fee_per_gas,
16932                    beneficiary: payload.miner,
16933                    prevrandao,
16934                    gas_limit: payload.gas_limit,
16935                };
16936                if let Some(previous) = self.latest_preconfirmation.as_ref()
16937                    && flashblock.same_payload(previous)
16938                    && flashblock.content_hash != previous.content_hash
16939                    && !flashblock.is_cumulative_successor_of(previous)
16940                {
16941                    return Err(SubscriberError::Provider(
16942                        "cumulative Flashblock transaction membership is non-monotonic".into(),
16943                    ));
16944                }
16945                (flashblock, false)
16946            }
16947        };
16948        Ok((flashblock, recover_pending_snapshot))
16949    }
16950
16951    async fn poll_op_pending_flashblock(
16952        &mut self,
16953        timing: FlashblockIngressTiming,
16954    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16955        match self
16956            .fetch_pending_flashblock_with_timing(None, timing)
16957            .await
16958        {
16959            Ok(event) => {
16960                self.consecutive_flashblock_poll_failures = 0;
16961                Ok(event)
16962            }
16963            Err(PendingFlashblockPollError::Request(error)) => {
16964                self.flashblocks_rpc_metrics.failed_requests = self
16965                    .flashblocks_rpc_metrics
16966                    .failed_requests
16967                    .saturating_add(1);
16968                self.consecutive_flashblock_poll_failures =
16969                    self.consecutive_flashblock_poll_failures.saturating_add(1);
16970                if self.consecutive_flashblock_poll_failures
16971                    >= self.config.max_consecutive_flashblock_poll_failures
16972                {
16973                    return Err(error);
16974                }
16975                tracing::warn!(
16976                    consecutive_failures = self.consecutive_flashblock_poll_failures,
16977                    failure_limit = self.config.max_consecutive_flashblock_poll_failures,
16978                    error = %error,
16979                    "Optimism pending-state Flashblocks request failed; retrying on the next tick"
16980                );
16981                Ok(None)
16982            }
16983            Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
16984        }
16985    }
16986
16987    #[cfg(test)]
16988    async fn fetch_pending_flashblock(
16989        &mut self,
16990        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
16991    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
16992        self.fetch_pending_flashblock_with_timing(
16993            indexed_recovery,
16994            FlashblockIngressTiming::new(Instant::now()),
16995        )
16996        .await
16997    }
16998
16999    async fn fetch_pending_flashblock_with_timing(
17000        &mut self,
17001        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
17002        timing: FlashblockIngressTiming,
17003    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
17004        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
17005            == Some(FlashblocksAdapter::PendingStatePolling);
17006        if samples_pending_range {
17007            let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
17008            if !self.reserve_flashblock_rpc_methods(fixed_methods) {
17009                return Ok(None);
17010            }
17011        }
17012        let state_provider = if samples_pending_range {
17013            self.flashblocks_state_provider
17014                .as_ref()
17015                .unwrap_or(&self.provider)
17016        } else {
17017            &self.provider
17018        };
17019        let latest = if samples_pending_range {
17020            None
17021        } else {
17022            self.flashblocks_rpc_metrics.canonical_head_requests = self
17023                .flashblocks_rpc_metrics
17024                .canonical_head_requests
17025                .saturating_add(1);
17026            Some(
17027                state_provider
17028                    .get_block_number()
17029                    .await
17030                    .map_err(pending_flashblock_request_error)?,
17031            )
17032        };
17033        self.flashblocks_rpc_metrics.pending_block_requests = self
17034            .flashblocks_rpc_metrics
17035            .pending_block_requests
17036            .saturating_add(1);
17037        let pending_block = if samples_pending_range {
17038            self.fetch_op_pending_block().await?
17039        } else {
17040            self.provider
17041                .get_block_by_number(BlockNumberOrTag::Pending)
17042                .await
17043                .map_err(pending_flashblock_request_error)?
17044        };
17045        let Some(block) = pending_block else {
17046            if self.config.preconfirmations == PreconfirmationMode::Required {
17047                return Err(PendingFlashblockPollError::Request(
17048                    SubscriberError::Provider(
17049                        "Flashblocks provider returned no pending block".into(),
17050                    ),
17051                ));
17052            }
17053            return Ok(None);
17054        };
17055        let latest = if samples_pending_range {
17056            self.certify_op_pending_parent(&block).await?.number()
17057        } else {
17058            latest.expect("non-OP pending recovery fetched a canonical height")
17059        };
17060        let header = block.header();
17061        if header.number() <= latest {
17062            return Ok(None);
17063        }
17064
17065        let provider = self.provider_ref.clone().ok_or({
17066            PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
17067                "Flashblocks require a stable provider ref from a pinned provider lease",
17068            ))
17069        })?;
17070        let parent_hash = Some(header.parent_hash());
17071        let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
17072            hashes.to_vec()
17073        } else if let Some(transactions) = block.transactions().as_transactions() {
17074            transactions
17075                .iter()
17076                .map(|transaction| transaction.tx_hash())
17077                .collect()
17078        } else {
17079            Vec::new()
17080        };
17081        let state_root = non_placeholder_hash(header.state_root());
17082        let transactions_root = non_placeholder_hash(header.transactions_root());
17083        let partial_block_hash = non_placeholder_hash(header.hash());
17084        let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
17085        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
17086            provider: &provider,
17087            payload_id: None,
17088            index: None,
17089            block_number: header.number(),
17090            partial_block_hash,
17091            parent_hash,
17092            state_root,
17093            transactions_root,
17094            transaction_hashes: &transaction_hashes,
17095            timestamp: Some(header.timestamp()),
17096            base_fee_per_gas: header.base_fee_per_gas(),
17097            beneficiary: Some(header.beneficiary()),
17098            prevrandao,
17099            gas_limit: Some(header.gas_limit()),
17100        });
17101        let flashblock = FlashblockRef {
17102            provider,
17103            payload_id: None,
17104            index: None,
17105            block_number: header.number(),
17106            content_hash,
17107            partial_block_hash,
17108            parent_hash,
17109            state_root,
17110            transactions_root,
17111            transaction_hashes,
17112            timestamp: Some(header.timestamp()),
17113            base_fee_per_gas: header.base_fee_per_gas(),
17114            beneficiary: Some(header.beneficiary()),
17115            prevrandao,
17116            gas_limit: Some(header.gas_limit()),
17117        };
17118        if samples_pending_range
17119            && self
17120                .latest_preconfirmation
17121                .as_ref()
17122                .is_some_and(|previous| !previous.same_payload(&flashblock))
17123        {
17124            // Revoke as soon as the sampled payload changes, before any
17125            // follow-up receipt await can fail or be cancelled.
17126            self.invalidate_preconfirmation_snapshot();
17127        }
17128        if let Some((payload_id, index, last_diff)) = indexed_recovery {
17129            self.base_flashblock_transactions = Some((
17130                payload_id,
17131                index,
17132                flashblock.transaction_hashes.clone(),
17133                last_diff,
17134            ));
17135        }
17136        let repeats_pending_snapshot = self
17137            .latest_preconfirmation
17138            .as_ref()
17139            .is_some_and(|previous| previous == &flashblock);
17140        if repeats_pending_snapshot && !samples_pending_range {
17141            return Ok(None);
17142        }
17143
17144        if let Some(previous) = self.latest_preconfirmation.as_ref()
17145            && flashblock.same_payload(previous)
17146            && !flashblock.is_cumulative_successor_of(previous)
17147        {
17148            if samples_pending_range {
17149                // OP pending-state reads are not atomic and paid endpoints can
17150                // briefly expose a shorter backend view. Never publish the
17151                // regression. Revoke the active overlay and require a fresh,
17152                // internally coherent sample on a later tick instead.
17153                self.invalidate_preconfirmation_snapshot();
17154                return Ok(Some(SubscriberEvent::FlashblockInvalidated));
17155            }
17156            return Err(PendingFlashblockPollError::Integrity(
17157                SubscriberError::Provider(
17158                    "sampled cumulative Flashblock transaction membership is non-monotonic".into(),
17159                ),
17160            ));
17161        }
17162
17163        let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
17164        if samples_pending_range {
17165            let (mut receipt_logs, completed_receipts, unavailable_receipts) =
17166                self.fetch_pending_transaction_receipts(&flashblock).await?;
17167            logs.append(&mut receipt_logs);
17168            logs.retain(|log| log.block_number == Some(flashblock.block_number));
17169            for log in &logs {
17170                let transaction_hash = log.transaction_hash.ok_or_else(|| {
17171                    PendingFlashblockPollError::Integrity(SubscriberError::Provider(
17172                        "pre-confirmed log is missing its transaction hash".into(),
17173                    ))
17174                })?;
17175                if !flashblock.contains_transaction(&transaction_hash) {
17176                    self.flashblocks_rpc_metrics.raced_samples =
17177                        self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
17178                    return Ok(None);
17179                }
17180            }
17181            let logs = self
17182                .filter_preconfirmed_logs(&flashblock, logs)
17183                .map_err(PendingFlashblockPollError::Integrity)?;
17184            self.preconfirmed_unavailable_receipts
17185                .extend(unavailable_receipts);
17186            for transaction_hash in &completed_receipts {
17187                self.preconfirmed_unavailable_receipts
17188                    .remove(transaction_hash);
17189            }
17190            self.preconfirmed_receipted_transactions
17191                .extend(completed_receipts);
17192            if repeats_pending_snapshot && logs.is_empty() {
17193                return Ok(None);
17194            }
17195            return Ok(Some(if logs.is_empty() {
17196                SubscriberEvent::FlashblockObserved
17197            } else {
17198                SubscriberEvent::PreconfirmedLogs {
17199                    flashblock,
17200                    logs,
17201                    timing,
17202                }
17203            }));
17204        }
17205        let logs = self
17206            .filter_preconfirmed_logs(&flashblock, logs)
17207            .map_err(PendingFlashblockPollError::Integrity)?;
17208        Ok(Some(if logs.is_empty() {
17209            SubscriberEvent::FlashblockObserved
17210        } else {
17211            SubscriberEvent::PreconfirmedLogs {
17212                flashblock,
17213                logs,
17214                timing,
17215            }
17216        }))
17217    }
17218
17219    async fn fetch_pending_logs(
17220        &mut self,
17221        pending_block_number: u64,
17222    ) -> Result<Vec<Log>, PendingFlashblockPollError> {
17223        let mut logs = Vec::new();
17224        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
17225            == Some(FlashblocksAdapter::PendingStatePolling);
17226        let state_provider = if samples_pending_range {
17227            self.flashblocks_state_provider
17228                .as_ref()
17229                .unwrap_or(&self.provider)
17230        } else {
17231            &self.provider
17232        };
17233        for filter in self.log_stream_filters() {
17234            self.flashblocks_rpc_metrics.pending_log_requests = self
17235                .flashblocks_rpc_metrics
17236                .pending_log_requests
17237                .saturating_add(1);
17238            let filter = if samples_pending_range {
17239                filter
17240                    .from_block(pending_block_number)
17241                    .to_block(BlockNumberOrTag::Pending)
17242            } else {
17243                filter
17244                    .from_block(BlockNumberOrTag::Pending)
17245                    .to_block(BlockNumberOrTag::Pending)
17246            };
17247            logs.extend(
17248                state_provider
17249                    .get_logs(&filter)
17250                    .await
17251                    .map_err(pending_flashblock_request_error)?,
17252            );
17253        }
17254        if samples_pending_range {
17255            logs.retain(|log| log.block_number == Some(pending_block_number));
17256        }
17257        Ok(logs)
17258    }
17259
17260    async fn fetch_pending_transaction_receipts(
17261        &mut self,
17262        flashblock: &FlashblockRef,
17263    ) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
17264        let receipt_allowance = self.pending_receipt_request_allowance();
17265        let receipt_limit = self
17266            .config
17267            .max_pending_transaction_receipts_per_tick
17268            .min(receipt_allowance);
17269        if receipt_limit == 0 {
17270            return Ok((Vec::new(), Vec::new(), Vec::new()));
17271        }
17272        let mut transaction_hashes = Vec::with_capacity(receipt_limit);
17273        for transaction_hash in &flashblock.transaction_hashes {
17274            if !self
17275                .preconfirmed_receipted_transactions
17276                .contains(transaction_hash)
17277                && !self
17278                    .preconfirmed_unavailable_receipts
17279                    .contains(transaction_hash)
17280            {
17281                transaction_hashes.push(*transaction_hash);
17282                if transaction_hashes.len() == receipt_limit {
17283                    break;
17284                }
17285            }
17286        }
17287        if transaction_hashes.len() < receipt_limit {
17288            for transaction_hash in &flashblock.transaction_hashes {
17289                if self
17290                    .preconfirmed_unavailable_receipts
17291                    .contains(transaction_hash)
17292                {
17293                    transaction_hashes.push(*transaction_hash);
17294                    if transaction_hashes.len() == receipt_limit {
17295                        break;
17296                    }
17297                }
17298            }
17299        }
17300        if transaction_hashes.is_empty() {
17301            return Ok((Vec::new(), Vec::new(), Vec::new()));
17302        }
17303        let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
17304        debug_assert!(reserved, "receipt allowance must remain reserved until use");
17305        if !reserved {
17306            return Ok((Vec::new(), Vec::new(), Vec::new()));
17307        }
17308        self.flashblocks_rpc_metrics.pending_receipt_requests = self
17309            .flashblocks_rpc_metrics
17310            .pending_receipt_requests
17311            .saturating_add(transaction_hashes.len() as u64);
17312        let state_provider = self
17313            .flashblocks_state_provider
17314            .as_ref()
17315            .unwrap_or(&self.provider);
17316        let client = state_provider.client();
17317        let mut batch = BatchRequest::new(client);
17318        let mut waiters = Vec::with_capacity(transaction_hashes.len());
17319        for transaction_hash in transaction_hashes {
17320            let waiter = batch
17321                .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
17322                .map_err(pending_flashblock_request_error)?;
17323            waiters.push((transaction_hash, waiter));
17324        }
17325        batch
17326            .send()
17327            .await
17328            .map_err(pending_flashblock_request_error)?;
17329        let mut logs = Vec::new();
17330        let mut completed = Vec::new();
17331        let mut unavailable = Vec::new();
17332        for (transaction_hash, waiter) in waiters {
17333            let value = waiter.await.map_err(pending_flashblock_request_error)?;
17334            if let Some(mut receipt_logs) =
17335                normalize_pending_transaction_receipt(transaction_hash, value)
17336                    .map_err(PendingFlashblockPollError::Integrity)?
17337            {
17338                self.flashblocks_rpc_metrics.pending_receipts_completed = self
17339                    .flashblocks_rpc_metrics
17340                    .pending_receipts_completed
17341                    .saturating_add(1);
17342                logs.append(&mut receipt_logs);
17343                completed.push(transaction_hash);
17344            } else {
17345                self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
17346                    .flashblocks_rpc_metrics
17347                    .pending_receipts_unavailable
17348                    .saturating_add(1);
17349                unavailable.push(transaction_hash);
17350            }
17351        }
17352        Ok((logs, completed, unavailable))
17353    }
17354
17355    fn pending_receipt_request_allowance(&mut self) -> usize {
17356        self.prune_flashblock_rpc_request_times();
17357        let rolling_capacity = self
17358            .config
17359            .max_flashblock_rpc_requests_per_second
17360            .saturating_sub(self.flashblock_rpc_request_times.len());
17361        rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
17362    }
17363
17364    fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
17365        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
17366        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
17367        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
17368        self.pending_receipt_requests_per_second_capacity()
17369            .checked_div(ticks_per_second)
17370            .unwrap_or(0)
17371    }
17372
17373    fn pending_receipt_requests_per_second_capacity(&self) -> usize {
17374        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
17375        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
17376        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
17377        let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
17378        let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
17379        if needs_header_block_stream(&self.interests) {
17380            let canonical_interval_nanos =
17381                self.config.canonical_head_poll_interval.as_nanos().max(1);
17382            let canonical_ticks = Duration::from_secs(1)
17383                .as_nanos()
17384                .div_ceil(canonical_interval_nanos);
17385            let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
17386            reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
17387        }
17388        self.config
17389            .max_flashblock_rpc_requests_per_second
17390            .saturating_sub(reserved_methods)
17391    }
17392
17393    fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
17394        self.prune_flashblock_rpc_request_times();
17395        if self
17396            .flashblock_rpc_request_times
17397            .len()
17398            .saturating_add(methods)
17399            > self.config.max_flashblock_rpc_requests_per_second
17400        {
17401            return false;
17402        }
17403        let now = Instant::now();
17404        for _ in 0..methods {
17405            self.flashblock_rpc_request_times.push_back(now);
17406        }
17407        true
17408    }
17409
17410    fn prune_flashblock_rpc_request_times(&mut self) {
17411        let now = Instant::now();
17412        while self
17413            .flashblock_rpc_request_times
17414            .front()
17415            .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
17416        {
17417            self.flashblock_rpc_request_times.pop_front();
17418        }
17419    }
17420
17421    fn filter_preconfirmed_logs(
17422        &mut self,
17423        flashblock: &FlashblockRef,
17424        mut logs: Vec<Log>,
17425    ) -> Result<Vec<Log>, SubscriberError> {
17426        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
17427            == Some(FlashblocksAdapter::PendingStatePolling);
17428        if self
17429            .latest_preconfirmation
17430            .as_ref()
17431            .is_some_and(|previous| !previous.same_payload(flashblock))
17432        {
17433            // A new payload revokes the previous overlay even when none of the
17434            // caller's log filters matched in the replacement. Otherwise a
17435            // quiet block could leave stale speculative signing authority
17436            // active until an unrelated canonical pool event arrived.
17437            self.invalidate_preconfirmation_snapshot();
17438        }
17439        if self
17440            .latest_preconfirmation
17441            .as_ref()
17442            .is_none_or(|previous| !previous.same_payload(flashblock))
17443        {
17444            self.preconfirmed_seen_logs.clear();
17445        }
17446        if let Some(previous) = self.latest_preconfirmation.as_ref()
17447            && previous.same_payload(flashblock)
17448            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
17449            && current_index < previous_index
17450        {
17451            return Ok(Vec::new());
17452        }
17453        self.latest_preconfirmation = Some(flashblock.clone());
17454
17455        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
17456        let mut filtered = Vec::new();
17457        for mut log in logs {
17458            if log.removed || log.block_number != Some(flashblock.block_number) {
17459                return Err(SubscriberError::Provider(
17460                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
17461                ));
17462            }
17463            let transaction_hash = log.transaction_hash.ok_or_else(|| {
17464                SubscriberError::Provider(
17465                    "pre-confirmed log is missing its transaction hash".into(),
17466                )
17467            })?;
17468            let log_index = log.log_index.ok_or_else(|| {
17469                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
17470            })?;
17471            let transaction_index =
17472                flashblock
17473                    .transaction_index(&transaction_hash)
17474                    .ok_or_else(|| {
17475                        SubscriberError::Provider(
17476                        "pre-confirmed log transaction is absent from the cumulative Flashblock"
17477                            .into(),
17478                    )
17479                    })?;
17480            if log
17481                .transaction_index
17482                .is_some_and(|reported| reported != transaction_index)
17483            {
17484                return Err(SubscriberError::Provider(
17485                    "pre-confirmed log transaction index disagrees with cumulative membership"
17486                        .into(),
17487                ));
17488            }
17489            let reported_hash = log.block_hash.and_then(non_placeholder_hash);
17490            if !samples_pending_range
17491                && let Some(reported) = reported_hash
17492                && reported != flashblock.content_hash
17493                && flashblock
17494                    .partial_block_hash
17495                    .is_some_and(|expected| reported != expected)
17496            {
17497                return Err(SubscriberError::Provider(
17498                    "pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
17499                        .into(),
17500                ));
17501            }
17502            log.block_hash = Some(flashblock.content_hash);
17503            log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
17504            log.transaction_index = Some(transaction_index);
17505            if self
17506                .preconfirmed_seen_logs
17507                .insert((transaction_hash, log_index))
17508                && log_matches_any_interest(&log, &self.interests)
17509            {
17510                filtered.push(log);
17511            }
17512        }
17513        Ok(filtered)
17514    }
17515
17516    async fn verify_event_log_blocks(
17517        &mut self,
17518        event: &SubscriberEvent<N>,
17519    ) -> Result<(), SubscriberError> {
17520        if !self.config.verify_log_block_context {
17521            return Ok(());
17522        }
17523        match event {
17524            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
17525            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
17526                for log in logs {
17527                    self.verify_log_block_context(log).await?;
17528                }
17529                Ok(())
17530            }
17531            #[cfg(feature = "raw-flashblocks-json")]
17532            SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()),
17533            SubscriberEvent::BlockHeader(_)
17534            | SubscriberEvent::PendingHash(_)
17535            | SubscriberEvent::PendingHashes(_)
17536            | SubscriberEvent::BasePendingLog { .. }
17537            | SubscriberEvent::BasePendingLogTimed { .. }
17538            | SubscriberEvent::BaseFlashblock { .. }
17539            | SubscriberEvent::BaseFlashblockTimed { .. }
17540            | SubscriberEvent::OpFlashblockTick
17541            | SubscriberEvent::OpFlashblockTickTimed(_)
17542            | SubscriberEvent::CanonicalHeadTick
17543            | SubscriberEvent::PreconfirmedLogs { .. }
17544            | SubscriberEvent::FlashblockInvalidated
17545            | SubscriberEvent::FlashblockObserved
17546            | SubscriberEvent::StreamTerminated(_) => Ok(()),
17547        }
17548    }
17549
17550    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
17551        if log.removed {
17552            return Ok(());
17553        }
17554        let number = log.block_number.ok_or_else(|| {
17555            SubscriberError::Provider(
17556                "canonical log is missing its block number during context verification".into(),
17557            )
17558        })?;
17559        let hash = log.block_hash.ok_or_else(|| {
17560            SubscriberError::Provider(
17561                "canonical log is missing its block hash during context verification".into(),
17562            )
17563        })?;
17564        let key = (number, hash);
17565        if self.verified_log_blocks.contains_key(&key) {
17566            return Ok(());
17567        }
17568        let provider = self
17569            .log_verification_provider
17570            .as_ref()
17571            .unwrap_or(&self.provider);
17572        let block = provider
17573            .get_block_by_number(BlockNumberOrTag::Number(number))
17574            .await
17575            .map_err(provider_error)?
17576            .ok_or_else(|| {
17577                SubscriberError::Provider(format!(
17578                    "canonical log block {number} is unavailable during context verification"
17579                ))
17580            })?;
17581        let header = block.header();
17582        let verified = BlockRef {
17583            number: header.number(),
17584            hash: header.hash(),
17585            parent_hash: Some(header.parent_hash()),
17586            timestamp: Some(header.timestamp()),
17587        };
17588        if verified.number != number
17589            || verified.hash != hash
17590            || log
17591                .block_timestamp
17592                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
17593        {
17594            return Err(SubscriberError::Provider(format!(
17595                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
17596            )));
17597        }
17598        self.verified_log_blocks.insert(key, verified);
17599        self.verified_log_block_order.push_back(key);
17600        let capacity = self.config.reconnect.dedupe_window.max(1);
17601        while self.verified_log_block_order.len() > capacity {
17602            if let Some(evicted) = self.verified_log_block_order.pop_front() {
17603                self.verified_log_blocks.remove(&evicted);
17604            }
17605        }
17606        Ok(())
17607    }
17608
17609    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
17610        self.enqueue_event_with_excluded_owners(event, None);
17611    }
17612
17613    fn buffer_reconcile_event_for_owners(
17614        &mut self,
17615        event: &SubscriberEvent<N>,
17616        target_epochs: &HashSet<SubscriberOwnerEpoch>,
17617    ) {
17618        match event {
17619            SubscriberEvent::Log { log, .. } => {
17620                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
17621            }
17622            SubscriberEvent::BackfilledLogs { logs, .. } => {
17623                for log in logs {
17624                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
17625                }
17626            }
17627            SubscriberEvent::Logs(logs) => {
17628                for log in logs {
17629                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
17630                }
17631            }
17632            #[cfg(feature = "raw-flashblocks-json")]
17633            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
17634            SubscriberEvent::BlockHeader(_)
17635            | SubscriberEvent::PendingHash(_)
17636            | SubscriberEvent::PendingHashes(_)
17637            | SubscriberEvent::BasePendingLog { .. }
17638            | SubscriberEvent::BasePendingLogTimed { .. }
17639            | SubscriberEvent::BaseFlashblock { .. }
17640            | SubscriberEvent::BaseFlashblockTimed { .. }
17641            | SubscriberEvent::OpFlashblockTick
17642            | SubscriberEvent::OpFlashblockTickTimed(_)
17643            | SubscriberEvent::CanonicalHeadTick
17644            | SubscriberEvent::PreconfirmedLogs { .. }
17645            | SubscriberEvent::FlashblockInvalidated
17646            | SubscriberEvent::FlashblockObserved
17647            | SubscriberEvent::StreamTerminated(_) => {}
17648        }
17649    }
17650
17651    fn buffer_reconcile_log_for_owners(
17652        &mut self,
17653        log: &Log,
17654        source: InputSource,
17655        target_epochs: &HashSet<SubscriberOwnerEpoch>,
17656    ) {
17657        let record = self.with_chain_id(log_input_record(log.clone(), source));
17658        let owners = self
17659            .staged_owners_for_record(&record)
17660            .into_iter()
17661            .filter(|owner| target_epochs.contains(owner))
17662            .collect::<Vec<_>>();
17663        if !owners.is_empty() {
17664            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
17665        }
17666    }
17667
17668    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
17669        let mut retained = VecDeque::new();
17670        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
17671            let mut promoted = Vec::new();
17672            buffered.owners.retain(|owner| {
17673                if target_epochs.contains(owner) {
17674                    promoted.push(owner.clone());
17675                    false
17676                } else {
17677                    true
17678                }
17679            });
17680            if promoted.is_empty() {
17681                retained.push_back(buffered);
17682                continue;
17683            }
17684            let promoted_record = if buffered.owners.is_empty() {
17685                buffered.record
17686            } else {
17687                let record = buffered.record.clone();
17688                retained.push_back(buffered);
17689                record
17690            };
17691            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
17692        }
17693        self.pending_reconcile_owner_records = retained;
17694    }
17695
17696    fn seed_reconciled_filter_anchors(
17697        &mut self,
17698        plans: &[SubscriberOwnerReconcilePlan<N>],
17699        through: u64,
17700    ) {
17701        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
17702            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
17703                continue;
17704            };
17705            let anchor = self
17706                .last_seen_log_blocks
17707                .entry(source_id)
17708                .or_insert(through);
17709            *anchor = (*anchor).max(through);
17710        }
17711    }
17712
17713    fn enqueue_event_excluding_owners(
17714        &mut self,
17715        event: SubscriberEvent<N>,
17716        excluded: &HashSet<SubscriberOwnerEpoch>,
17717    ) {
17718        self.enqueue_event_with_excluded_owners(event, Some(excluded));
17719    }
17720
17721    fn enqueue_event_with_excluded_owners(
17722        &mut self,
17723        event: SubscriberEvent<N>,
17724        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17725    ) {
17726        match event {
17727            SubscriberEvent::Log { source_id, log } => {
17728                if log_matches_any_interest(&log, &self.interests) {
17729                    let record = log_input_record(log, InputSource::Subscription);
17730                    self.note_log_block(source_id, &record);
17731                    self.enqueue_record_with_excluded_owners(record, excluded);
17732                }
17733            }
17734            SubscriberEvent::BackfilledLogs { source_id, logs } => {
17735                self.enqueue_backfilled_logs_with_excluded_owners(
17736                    logs,
17737                    Some(source_id),
17738                    None,
17739                    None,
17740                    excluded,
17741                );
17742            }
17743            SubscriberEvent::Logs(logs) => {
17744                for log in logs {
17745                    if log_matches_any_interest(&log, &self.interests) {
17746                        self.enqueue_record_with_excluded_owners(
17747                            log_input_record(log, InputSource::Poll),
17748                            excluded,
17749                        );
17750                    }
17751                }
17752            }
17753            SubscriberEvent::BlockHeader(header) => {
17754                if needs_header_block_stream(&self.interests) {
17755                    let record = block_header_input_record::<N>(header);
17756                    self.enqueue_record_with_excluded_owners(record, excluded);
17757                }
17758            }
17759            SubscriberEvent::PendingHash(hash) => {
17760                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
17761                self.enqueue_record_with_excluded_owners(record, excluded);
17762            }
17763            SubscriberEvent::PendingHashes(hashes) => {
17764                for hash in hashes {
17765                    self.enqueue_record_with_excluded_owners(
17766                        pending_hash_input_record::<N>(hash, InputSource::Poll),
17767                        excluded,
17768                    );
17769                }
17770            }
17771            SubscriberEvent::PreconfirmedLogs {
17772                flashblock,
17773                logs,
17774                timing,
17775            } => {
17776                for log in logs {
17777                    let record = self
17778                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
17779                    self.push_pending_record(SubscriberInputRecord {
17780                        record,
17781                        scope: SubscriberInputScope::Preconfirmed,
17782                        preconfirmation_timing: Some(timing),
17783                    });
17784                }
17785            }
17786            SubscriberEvent::FlashblockInvalidated => {
17787                self.pending_preconfirmation_invalidation = true;
17788            }
17789            SubscriberEvent::BasePendingLog { .. }
17790            | SubscriberEvent::BasePendingLogTimed { .. }
17791            | SubscriberEvent::BaseFlashblock { .. }
17792            | SubscriberEvent::BaseFlashblockTimed { .. }
17793            | SubscriberEvent::OpFlashblockTick
17794            | SubscriberEvent::OpFlashblockTickTimed(_)
17795            | SubscriberEvent::CanonicalHeadTick
17796            | SubscriberEvent::FlashblockObserved => {}
17797            #[cfg(feature = "raw-flashblocks-json")]
17798            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
17799            SubscriberEvent::StreamTerminated(_) => {}
17800        }
17801    }
17802
17803    fn enqueue_backfilled_logs(
17804        &mut self,
17805        logs: Vec<Log>,
17806        source_id: Option<usize>,
17807        owner: Option<&SubscriberOwnerEpoch>,
17808        range: Option<SubscriberBackfill>,
17809    ) {
17810        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
17811    }
17812
17813    fn enqueue_backfilled_logs_with_excluded_owners(
17814        &mut self,
17815        logs: Vec<Log>,
17816        source_id: Option<usize>,
17817        owner: Option<&SubscriberOwnerEpoch>,
17818        range: Option<SubscriberBackfill>,
17819        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17820    ) {
17821        for log in logs {
17822            if range.as_ref().is_some_and(|range| {
17823                log.block_number.is_some_and(|block| {
17824                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
17825                })
17826            }) {
17827                continue;
17828            }
17829            let matches = match owner {
17830                Some(epoch) => self
17831                    .owned_interests
17832                    .iter()
17833                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
17834                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
17835                None => log_matches_any_interest(&log, &self.interests),
17836            };
17837            if matches {
17838                let record = log_input_record(log, InputSource::Backfill);
17839                if let Some(epoch) = owner {
17840                    self.enqueue_owner_record(record, epoch.clone());
17841                } else {
17842                    if let Some(source_id) = source_id {
17843                        self.note_log_block(source_id, &record);
17844                    }
17845                    self.enqueue_record_with_excluded_owners(record, excluded);
17846                }
17847            }
17848        }
17849    }
17850
17851    fn enqueue_compat_owner_backfilled_logs(
17852        &mut self,
17853        logs: Vec<Log>,
17854        owner: &HandlerId,
17855        range: SubscriberBackfill,
17856    ) {
17857        let interests = self
17858            .owned_interests
17859            .iter()
17860            .find(|entry| {
17861                &entry.owner == owner
17862                    && entry.epoch.is_none()
17863                    && entry.state == SubscriberOwnerState::Active
17864            })
17865            .map(|entry| entry.interests.clone());
17866        let Some(interests) = interests else {
17867            return;
17868        };
17869        for log in logs {
17870            if log.block_number.is_some_and(|block| {
17871                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
17872            }) || !log_matches_any_interest(&log, &interests)
17873            {
17874                continue;
17875            }
17876            let record = log_input_record(log, InputSource::Backfill);
17877            self.enqueue_compat_owner_record(record, owner.clone());
17878        }
17879    }
17880
17881    async fn reconnect_source_stream(
17882        &mut self,
17883        source: SubscriberStreamSource,
17884    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17885        if !source.is_pubsub() {
17886            return Err(stream_terminated_error(&source));
17887        }
17888
17889        if !self.config.reconnect.enabled {
17890            return Err(SubscriberError::Provider(format!(
17891                "Alloy subscriber {} stream terminated and reconnect is disabled",
17892                source.label()
17893            )));
17894        }
17895
17896        let mut attempts = 0usize;
17897        let mut delay = self.config.reconnect.initial_delay;
17898        let mut retry_delay = self.config.reconnect.retry_delay;
17899
17900        loop {
17901            attempts = attempts.saturating_add(1);
17902            if !delay.is_zero() {
17903                tokio::time::sleep(delay).await;
17904            }
17905
17906            match self.reconnect_source_once(source.clone()).await {
17907                Ok(backfill_event) => return Ok(backfill_event),
17908                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
17909                    return Err(SubscriberError::Provider(format!(
17910                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
17911                        source.label()
17912                    )));
17913                }
17914                Err(error) => {
17915                    tracing::warn!(
17916                        stream = source.label(),
17917                        attempts,
17918                        error = %error,
17919                        "Alloy subscriber reconnect attempt failed"
17920                    );
17921                    delay = retry_delay;
17922                    retry_delay =
17923                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
17924                }
17925            }
17926        }
17927    }
17928
17929    async fn reconnect_source_once(
17930        &mut self,
17931        source: SubscriberStreamSource,
17932    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17933        if matches!(
17934            &self.state,
17935            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
17936        ) {
17937            // A prior attempt installed the stream before its catch-up await
17938            // failed or was cancelled. Retry only the unfinished historical
17939            // window; reconnecting again would create a duplicate live source.
17940            let backfill_event = self.backfill_reconnected_source(&source).await?;
17941            self.pending_source_backfills
17942                .retain(|pending| !pending.same_key(&source));
17943            return Ok(backfill_event);
17944        }
17945        let stream = self.connect_source_stream(source.clone()).await?;
17946        if !matches!(self.state, AlloySubscriberState::Active(_)) {
17947            return Err(SubscriberError::Provider(
17948                "Alloy subscriber state changed before reconnect completed".to_owned(),
17949            ));
17950        }
17951        self.install_source_stream(source.clone(), stream);
17952        if self.source_requires_backfill(&source) {
17953            self.queue_source_backfill(source.clone());
17954        }
17955        let backfill_event = self.backfill_reconnected_source(&source).await?;
17956        self.pending_source_backfills
17957            .retain(|pending| !pending.same_key(&source));
17958
17959        Ok(backfill_event)
17960    }
17961
17962    async fn backfill_reconnected_source(
17963        &mut self,
17964        source: &SubscriberStreamSource,
17965    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17966        if source.is_flashblocks() {
17967            return Ok(None);
17968        }
17969        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
17970            return Ok(None);
17971        };
17972        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
17973            return Ok(None);
17974        };
17975
17976        let latest = self
17977            .provider
17978            .get_block_number()
17979            .await
17980            .map_err(provider_error)?;
17981        if latest < from_block {
17982            return Ok(None);
17983        }
17984
17985        let logs = self
17986            .provider
17987            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
17988            .await
17989            .map_err(provider_error)?;
17990        Ok(Some(SubscriberEvent::BackfilledLogs {
17991            source_id: *id,
17992            logs,
17993        }))
17994    }
17995
17996    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
17997        if let Some(block) = record.context.block.as_ref() {
17998            self.last_seen_log_blocks.insert(source_id, block.number);
17999        }
18000    }
18001
18002    fn enqueue_record_with_excluded_owners(
18003        &mut self,
18004        record: ReactiveInputRecord<N>,
18005        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
18006    ) {
18007        let record = self.with_chain_id(record);
18008        let mut owners = self.staged_owners_for_record(&record);
18009        if let Some(excluded) = excluded {
18010            owners.retain(|owner| !excluded.contains(owner));
18011        }
18012        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
18013        let owners = self.filter_recent_owner_duplicates(&record, owners);
18014        let compatibility_owners = self.compatibility_owners_for_record(&record);
18015        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
18016            .into_iter()
18017            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
18018        if canonical_duplicate {
18019            if !owners.is_empty() {
18020                self.push_pending_record(SubscriberInputRecord {
18021                    record: record.clone(),
18022                    scope: SubscriberInputScope::OwnerOnly { owners },
18023                    preconfirmation_timing: None,
18024                });
18025            }
18026            if !newly_served.is_empty() {
18027                for owner in &newly_served {
18028                    self.remember_compatibility_owner_record(&record, owner);
18029                }
18030                self.push_pending_record(SubscriberInputRecord {
18031                    record,
18032                    scope: SubscriberInputScope::OwnerOnlyHandlers {
18033                        owners: newly_served,
18034                    },
18035                    preconfirmation_timing: None,
18036                });
18037            }
18038            return;
18039        }
18040        self.remember_record(&record);
18041        for owner in already_served.iter().chain(&newly_served) {
18042            self.remember_compatibility_owner_record(&record, owner);
18043        }
18044        self.push_pending_record(SubscriberInputRecord {
18045            record,
18046            scope: if already_served.is_empty() {
18047                SubscriberInputScope::Canonical { owners }
18048            } else {
18049                SubscriberInputScope::CanonicalResidual {
18050                    owners,
18051                    excluded: already_served,
18052                }
18053            },
18054            preconfirmation_timing: None,
18055        });
18056    }
18057
18058    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
18059        let record = self.with_chain_id(record);
18060        if self.compatibility_owner_has_seen(&record, &owner) {
18061            return;
18062        }
18063        self.remember_compatibility_owner_record(&record, &owner);
18064        self.push_pending_record(SubscriberInputRecord {
18065            record,
18066            scope: SubscriberInputScope::OwnerOnlyHandlers {
18067                owners: vec![owner],
18068            },
18069            preconfirmation_timing: None,
18070        });
18071    }
18072
18073    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
18074        self.owned_interests
18075            .iter()
18076            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
18077            .filter(|entry| {
18078                entry
18079                    .interests
18080                    .iter()
18081                    .any(|interest| interest_matches(interest, &record.input))
18082            })
18083            .map(|entry| entry.owner.clone())
18084            .collect()
18085    }
18086
18087    fn compatibility_owner_has_seen(
18088        &self,
18089        record: &ReactiveInputRecord<N>,
18090        owner: &HandlerId,
18091    ) -> bool {
18092        should_dedupe_record(record)
18093            && self
18094                .recent_compat_owner_input_ref_sets
18095                .get(owner)
18096                .is_some_and(|seen| seen.contains(&record.input_ref()))
18097    }
18098
18099    fn remember_compatibility_owner_record(
18100        &mut self,
18101        record: &ReactiveInputRecord<N>,
18102        owner: &HandlerId,
18103    ) {
18104        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
18105            return;
18106        }
18107        let input_ref = record.input_ref();
18108        let seen = self
18109            .recent_compat_owner_input_ref_sets
18110            .entry(owner.clone())
18111            .or_default();
18112        if !seen.insert(input_ref) {
18113            return;
18114        }
18115        let recent = self
18116            .recent_compat_owner_input_refs
18117            .entry(owner.clone())
18118            .or_default();
18119        recent.push_back(input_ref);
18120        while recent.len() > self.config.reconnect.dedupe_window {
18121            if let Some(evicted) = recent.pop_front() {
18122                seen.remove(&evicted);
18123            }
18124        }
18125    }
18126
18127    fn enqueue_owner_record(
18128        &mut self,
18129        record: ReactiveInputRecord<N>,
18130        owner: SubscriberOwnerEpoch,
18131    ) {
18132        self.enqueue_owner_record_for_owners(record, vec![owner]);
18133    }
18134
18135    fn enqueue_owner_record_for_owners(
18136        &mut self,
18137        record: ReactiveInputRecord<N>,
18138        owners: Vec<SubscriberOwnerEpoch>,
18139    ) {
18140        self.enqueue_owner_record_for_owners_inner(record, owners, true);
18141    }
18142
18143    fn enqueue_owner_record_for_owners_unmerged(
18144        &mut self,
18145        record: ReactiveInputRecord<N>,
18146        owners: Vec<SubscriberOwnerEpoch>,
18147    ) {
18148        self.enqueue_owner_record_for_owners_inner(record, owners, false);
18149    }
18150
18151    fn enqueue_owner_record_for_owners_inner(
18152        &mut self,
18153        record: ReactiveInputRecord<N>,
18154        owners: Vec<SubscriberOwnerEpoch>,
18155        merge_pending: bool,
18156    ) {
18157        let record = self.with_chain_id(record);
18158        let owners = self.filter_recent_owner_duplicates(&record, owners);
18159        if owners.is_empty() {
18160            return;
18161        }
18162        if merge_pending
18163            && should_dedupe_record(&record)
18164            && self.config.reconnect.dedupe_window != 0
18165        {
18166            let input_ref = record.input_ref();
18167            if let Some(pending) = self
18168                .pending_records
18169                .iter_mut()
18170                .rev()
18171                .find(|pending| pending.record.input_ref() == input_ref)
18172            {
18173                let pending_owners = match &mut pending.scope {
18174                    SubscriberInputScope::Canonical { owners }
18175                    | SubscriberInputScope::CanonicalResidual { owners, .. }
18176                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
18177                    SubscriberInputScope::OwnerOnlyHandlers { .. }
18178                    | SubscriberInputScope::Preconfirmed => None,
18179                };
18180                if let Some(pending_owners) = pending_owners {
18181                    for owner in owners {
18182                        if !pending_owners.contains(&owner) {
18183                            pending_owners.push(owner);
18184                        }
18185                    }
18186                    return;
18187                }
18188            }
18189        }
18190        self.push_pending_record(SubscriberInputRecord {
18191            record,
18192            scope: SubscriberInputScope::OwnerOnly { owners },
18193            preconfirmation_timing: None,
18194        });
18195    }
18196
18197    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
18198        if self.pending_record_count() >= self.config.max_pending_records {
18199            self.note_resource_error(format!(
18200                "pending record queues reached the configured limit of {}",
18201                self.config.max_pending_records
18202            ));
18203            return;
18204        }
18205        self.pending_records.push_back(record);
18206    }
18207
18208    fn ensure_pending_record_capacity(
18209        &mut self,
18210        additional: usize,
18211        operation: &str,
18212    ) -> Result<(), SubscriberError> {
18213        let required = self.pending_record_count().saturating_add(additional);
18214        if required > self.config.max_pending_records {
18215            self.note_resource_error(format!(
18216                "{operation} require {required} pending records, above the configured limit of {}",
18217                self.config.max_pending_records
18218            ));
18219            return self.check_resource_error();
18220        }
18221        Ok(())
18222    }
18223
18224    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
18225        if self.pending_record_count() >= self.config.max_pending_records {
18226            self.note_resource_error(format!(
18227                "pending record queues reached the configured limit of {}",
18228                self.config.max_pending_records
18229            ));
18230            return;
18231        }
18232        self.pending_reconcile_owner_records.push_back(record);
18233    }
18234
18235    fn pending_record_count(&self) -> usize {
18236        self.pending_records
18237            .len()
18238            .saturating_add(self.pending_reconcile_owner_records.len())
18239    }
18240
18241    fn note_resource_error(&mut self, message: String) {
18242        if self.resource_error.is_none() {
18243            self.resource_error = Some(message);
18244        }
18245    }
18246
18247    fn check_resource_error(&self) -> Result<(), SubscriberError> {
18248        match &self.resource_error {
18249            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
18250            None => Ok(()),
18251        }
18252    }
18253
18254    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
18255        record.context.chain_id = self.chain_id;
18256        if self.config.verify_log_block_context
18257            && let ReactiveInput::Log(log) = &record.input
18258            && !log.removed
18259            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
18260            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
18261        {
18262            record.context.block = Some(verified);
18263            record.context.chain_status = ChainStatus::Included {
18264                block: verified,
18265                confirmations: 0,
18266            };
18267        }
18268        record
18269    }
18270
18271    fn staged_owners_for_record(
18272        &self,
18273        record: &ReactiveInputRecord<N>,
18274    ) -> Vec<SubscriberOwnerEpoch> {
18275        self.owned_interests
18276            .iter()
18277            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
18278            .filter(|entry| {
18279                entry
18280                    .interests
18281                    .iter()
18282                    .any(|interest| interest_matches(interest, &record.input))
18283            })
18284            .filter_map(|entry| entry.epoch.clone())
18285            .collect()
18286    }
18287
18288    fn filter_recent_owner_duplicates(
18289        &mut self,
18290        record: &ReactiveInputRecord<N>,
18291        owners: Vec<SubscriberOwnerEpoch>,
18292    ) -> Vec<SubscriberOwnerEpoch> {
18293        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
18294            return owners;
18295        }
18296        let input_ref = record.input_ref();
18297        let window = self.config.reconnect.dedupe_window;
18298        owners
18299            .into_iter()
18300            .filter(|owner| {
18301                let seen = self
18302                    .recent_owner_input_ref_sets
18303                    .entry(owner.clone())
18304                    .or_default();
18305                if !seen.insert(input_ref) {
18306                    return false;
18307                }
18308                let recent = self
18309                    .recent_owner_input_refs
18310                    .entry(owner.clone())
18311                    .or_default();
18312                recent.push_back(input_ref);
18313                while recent.len() > window {
18314                    if let Some(evicted) = recent.pop_front() {
18315                        seen.remove(&evicted);
18316                    }
18317                }
18318                true
18319            })
18320            .collect()
18321    }
18322
18323    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
18324        if !should_dedupe_record(record) {
18325            return false;
18326        }
18327        self.recent_input_ref_set.contains(&record.input_ref())
18328    }
18329
18330    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
18331        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
18332            return;
18333        }
18334
18335        let input_ref = record.input_ref();
18336        if !self.recent_input_ref_set.insert(input_ref) {
18337            return;
18338        }
18339        self.recent_input_refs.push_back(input_ref);
18340
18341        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
18342            if let Some(evicted) = self.recent_input_refs.pop_front() {
18343                self.recent_input_ref_set.remove(&evicted);
18344            }
18345        }
18346    }
18347}
18348
18349fn stream_with_termination<N, S>(
18350    stream: S,
18351    source: SubscriberStreamSource,
18352) -> BoxStream<'static, SubscriberEvent<N>>
18353where
18354    N: Network + 'static,
18355    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
18356{
18357    stream
18358        .chain(stream::once(async move {
18359            SubscriberEvent::StreamTerminated(source)
18360        }))
18361        .boxed()
18362}
18363
18364fn flashblock_reconnect_future<N>(
18365    provider: RootProvider<N>,
18366    source: SubscriberStreamSource,
18367    channel_size: usize,
18368    reconnect: SubscriberReconnectConfig,
18369    first_delay: Duration,
18370    flashblock_poll_interval: Duration,
18371) -> FlashblockReconnectFuture<N>
18372where
18373    N: Network + 'static,
18374{
18375    Box::pin(async move {
18376        if !reconnect.enabled {
18377            let error = SubscriberError::Provider(format!(
18378                "Alloy subscriber {} stream terminated and reconnect is disabled",
18379                source.label()
18380            ));
18381            return (source, Err(error));
18382        }
18383
18384        let mut attempts = 0_usize;
18385        let mut delay = first_delay;
18386        let mut retry_delay = reconnect.retry_delay;
18387        loop {
18388            attempts = attempts.saturating_add(1);
18389            if !delay.is_zero() {
18390                tokio::time::sleep(delay).await;
18391            }
18392            match connect_flashblock_source_once(
18393                &provider,
18394                source.clone(),
18395                channel_size,
18396                flashblock_poll_interval,
18397            )
18398            .await
18399            {
18400                Ok(stream) => return (source, Ok(stream)),
18401                Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
18402                    return (
18403                        source.clone(),
18404                        Err(SubscriberError::Provider(format!(
18405                            "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
18406                            source.label()
18407                        ))),
18408                    );
18409                }
18410                Err(error) => {
18411                    tracing::warn!(
18412                        stream = source.label(),
18413                        attempts,
18414                        error = %error,
18415                        "Flashblocks reconnect attempt failed"
18416                    );
18417                    delay = retry_delay;
18418                    retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
18419                }
18420            }
18421        }
18422    })
18423}
18424
18425async fn connect_flashblock_source_once<N>(
18426    provider: &RootProvider<N>,
18427    source: SubscriberStreamSource,
18428    channel_size: usize,
18429    flashblock_poll_interval: Duration,
18430) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
18431where
18432    N: Network + 'static,
18433{
18434    #[cfg(not(feature = "reactive-ws"))]
18435    let _ = provider;
18436
18437    match source {
18438        SubscriberStreamSource::BasePendingLog { id, filter } => {
18439            #[cfg(feature = "reactive-ws")]
18440            {
18441                let source = SubscriberStreamSource::BasePendingLog {
18442                    id,
18443                    filter: filter.clone(),
18444                };
18445                let params = base_pending_log_filter(&filter)?;
18446                let stream = provider
18447                    .subscribe::<_, Log>(("pendingLogs", params))
18448                    .channel_size(channel_size.max(1))
18449                    .await
18450                    .map_err(provider_error)?
18451                    .into_stream()
18452                    .map(move |log| SubscriberEvent::BasePendingLogTimed {
18453                        source_id: id,
18454                        log,
18455                        timing: FlashblockIngressTiming::new(Instant::now()),
18456                    });
18457                Ok(stream_with_termination(stream, source))
18458            }
18459            #[cfg(not(feature = "reactive-ws"))]
18460            {
18461                let _ = (id, filter, channel_size);
18462                Err(SubscriberError::Unsupported(
18463                    "Base Flashblocks require the reactive-ws feature",
18464                ))
18465            }
18466        }
18467        SubscriberStreamSource::BaseFlashblocks => {
18468            #[cfg(feature = "reactive-ws")]
18469            {
18470                let stream = provider
18471                    .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
18472                    .channel_size(channel_size.max(1))
18473                    .await
18474                    .map_err(provider_error)?
18475                    .into_stream()
18476                    .map(|payload| SubscriberEvent::BaseFlashblockTimed {
18477                        payload,
18478                        timing: FlashblockIngressTiming::new(Instant::now()),
18479                    });
18480                Ok(stream_with_termination(
18481                    stream,
18482                    SubscriberStreamSource::BaseFlashblocks,
18483                ))
18484            }
18485            #[cfg(not(feature = "reactive-ws"))]
18486            {
18487                let _ = channel_size;
18488                Err(SubscriberError::Unsupported(
18489                    "Base Flashblocks require the reactive-ws feature",
18490                ))
18491            }
18492        }
18493        SubscriberStreamSource::OpPendingFlashblocks => {
18494            let first_tick = tokio::time::Instant::now();
18495            let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
18496            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
18497            let stream = stream::unfold(interval, |mut interval| async move {
18498                interval.tick().await;
18499                Some((
18500                    SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(
18501                        Instant::now(),
18502                    )),
18503                    interval,
18504                ))
18505            });
18506            Ok(stream_with_termination(
18507                stream,
18508                SubscriberStreamSource::OpPendingFlashblocks,
18509            ))
18510        }
18511        source => Err(SubscriberError::InvalidConfig(match source {
18512            SubscriberStreamSource::PubSubLog { .. }
18513            | SubscriberStreamSource::CanonicalHeadPolling
18514            | SubscriberStreamSource::PubSubPendingHashes
18515            | SubscriberStreamSource::PubSubBlockHeaders
18516            | SubscriberStreamSource::PollingLog { .. }
18517            | SubscriberStreamSource::PollingPendingHashes => {
18518                "Flashblocks reconnect received a canonical source"
18519            }
18520            SubscriberStreamSource::BasePendingLog { .. }
18521            | SubscriberStreamSource::BaseFlashblocks
18522            | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
18523            #[cfg(feature = "raw-flashblocks-json")]
18524            SubscriberStreamSource::ExternalFlashblockUpdates => {
18525                "Flashblocks reconnect cannot own an application-managed source"
18526            }
18527        })),
18528    }
18529}
18530
18531fn aggregate_interests<N: Network>(
18532    base: &[ReactiveInterest<N>],
18533    owned: &[OwnedSubscriberInterests<N>],
18534) -> Vec<ReactiveInterest<N>> {
18535    base.iter()
18536        .cloned()
18537        .chain(
18538            owned
18539                .iter()
18540                .flat_map(|entry| entry.interests.iter().cloned()),
18541        )
18542        .collect()
18543}
18544
18545fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
18546    SubscriberError::Provider(format!(
18547        "Alloy subscriber {} stream terminated before the subscriber was stopped",
18548        source.label()
18549    ))
18550}
18551
18552fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
18553    config
18554        .max_attempts
18555        .is_some_and(|max_attempts| attempts >= max_attempts)
18556}
18557
18558fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
18559    if current.is_zero() {
18560        return current;
18561    }
18562    current.checked_mul(2).unwrap_or(max).min(max)
18563}
18564
18565fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
18566    match &record.input {
18567        ReactiveInput::Log(log) => {
18568            is_canonical_status(&record.context.chain_status) && !log.removed
18569        }
18570        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
18571        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
18572    }
18573}
18574
18575#[cfg(test)]
18576mod subscriber_helper_tests {
18577    use super::*;
18578    use alloy_json_rpc::{RequestPacket, ResponsePacket};
18579    use alloy_provider::ProviderBuilder;
18580    use alloy_rpc_client::RpcClient;
18581    use alloy_transport::{TransportError, TransportFut, mock::Asserter};
18582    use std::task::{Context, Poll};
18583    use tower::Service;
18584
18585    #[derive(Clone, Debug)]
18586    struct NeverRespondingTransport;
18587
18588    impl Service<RequestPacket> for NeverRespondingTransport {
18589        type Response = ResponsePacket;
18590        type Error = TransportError;
18591        type Future = TransportFut<'static>;
18592
18593        fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
18594            Poll::Ready(Ok(()))
18595        }
18596
18597        fn call(&mut self, _request: RequestPacket) -> Self::Future {
18598            Box::pin(futures::future::pending())
18599        }
18600    }
18601
18602    fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
18603        BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
18604            payload_id: FixedBytes::repeat_byte(0x11),
18605            index: 0,
18606            base: Some(BaseFlashblockBase {
18607                parent_hash: B256::repeat_byte(100),
18608                block_number: 101,
18609                timestamp: 1_700_000_101,
18610                gas_limit: Some(30_000_000),
18611                base_fee_per_gas: Some(7),
18612                beneficiary: Some(Address::repeat_byte(0xcb)),
18613                prevrandao: Some(B256::repeat_byte(0x77)),
18614            }),
18615            diff: BaseFlashblockDiff {
18616                state_root,
18617                block_hash: B256::ZERO,
18618                transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
18619                transactions_root: None,
18620            },
18621            metadata: None,
18622        })
18623    }
18624
18625    fn base_flashblock_event(payload: BaseFlashblockWirePayload) -> SubscriberEvent<Ethereum> {
18626        SubscriberEvent::BaseFlashblockTimed {
18627            payload,
18628            timing: FlashblockIngressTiming::new(Instant::now()),
18629        }
18630    }
18631
18632    #[test]
18633    fn duplicate_flashblock_transaction_membership_is_rejected() {
18634        let transaction = format!("{:#x}", B256::repeat_byte(0x41));
18635        let transactions = vec![
18636            serde_json::Value::String(transaction.clone()),
18637            serde_json::Value::String(transaction),
18638        ];
18639        assert!(matches!(
18640            flashblock_transaction_hashes(&transactions),
18641            Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
18642        ));
18643    }
18644
18645    #[test]
18646    fn conflicting_duplicate_indexed_flashblock_is_rejected() {
18647        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18648        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18649            provider,
18650            SubscriberMode::PubSub,
18651            SubscriberConfig::default(),
18652        )
18653        .with_provider_ref(ProviderRef::new("base-paid", 7));
18654        subscriber.chain_id = Some(8_453);
18655
18656        subscriber
18657            .accept_base_flashblock(indexed_flashblock(
18658                B256::repeat_byte(0x41),
18659                B256::repeat_byte(0xa1),
18660            ))
18661            .expect("first indexed preview");
18662        assert!(matches!(
18663            subscriber.accept_base_flashblock(indexed_flashblock(
18664                B256::repeat_byte(0x42),
18665                B256::repeat_byte(0xa2),
18666            )),
18667            Err(SubscriberError::Provider(ref message))
18668                if message.contains("conflicting duplicate")
18669        ));
18670    }
18671
18672    #[tokio::test]
18673    async fn duplicate_index_with_changed_commitment_is_rejected() {
18674        let transaction = B256::repeat_byte(0x41);
18675        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18676        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18677            provider,
18678            SubscriberMode::PubSub,
18679            SubscriberConfig::default(),
18680        )
18681        .with_provider_ref(ProviderRef::new("base-paid", 7));
18682        subscriber.chain_id = Some(8_453);
18683
18684        subscriber
18685            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
18686                transaction,
18687                B256::repeat_byte(0xa1),
18688            )))
18689            .await
18690            .expect("first indexed preview");
18691
18692        let BaseFlashblockWirePayload::Indexed(mut conflicting) =
18693            indexed_flashblock(transaction, B256::repeat_byte(0xa1))
18694        else {
18695            unreachable!()
18696        };
18697        conflicting.diff.state_root = B256::repeat_byte(0xbb);
18698        assert!(matches!(
18699            subscriber
18700                .normalize_flashblock_event(base_flashblock_event(
18701                    BaseFlashblockWirePayload::Indexed(conflicting),
18702                ))
18703                .await,
18704            Err(SubscriberError::Provider(ref message))
18705                if message.contains("conflicting duplicate indexed Flashblock content")
18706        ));
18707    }
18708
18709    #[tokio::test]
18710    async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
18711        let transaction_a = B256::repeat_byte(0x41);
18712        let transaction_b = B256::repeat_byte(0x42);
18713        let transaction_c = B256::repeat_byte(0x43);
18714        let transaction_d = B256::repeat_byte(0x44);
18715        let asserter = Asserter::new();
18716        asserter.push_success(&100_u64);
18717        let pending = rpc_block(101, B256::ZERO).with_transactions(
18718            alloy_network::primitives::BlockTransactions::Hashes(vec![
18719                transaction_a,
18720                transaction_b,
18721                transaction_c,
18722            ]),
18723        );
18724        asserter.push_success(&Some(pending));
18725        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18726        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18727            provider,
18728            SubscriberMode::PubSub,
18729            SubscriberConfig::default(),
18730        )
18731        .with_provider_ref(ProviderRef::new("base-paid", 7));
18732        subscriber.chain_id = Some(8_453);
18733
18734        subscriber
18735            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
18736                transaction_a,
18737                B256::repeat_byte(0xa1),
18738            )))
18739            .await
18740            .expect("index zero preview");
18741        let BaseFlashblockWirePayload::Indexed(mut gap) =
18742            indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
18743        else {
18744            unreachable!()
18745        };
18746        gap.index = 2;
18747        gap.base = None;
18748        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18749        subscriber
18750            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
18751                gap,
18752            )))
18753            .await
18754            .expect("the missing index is recovered from pending state");
18755
18756        let BaseFlashblockWirePayload::Indexed(mut next) =
18757            indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
18758        else {
18759            unreachable!()
18760        };
18761        next.index = 3;
18762        next.base = None;
18763        next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18764        let (next, recover) = subscriber
18765            .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
18766            .expect("the next diff extends the recovered cumulative set");
18767        assert!(!recover);
18768        assert_eq!(
18769            next.transaction_hashes,
18770            vec![transaction_a, transaction_b, transaction_c, transaction_d]
18771        );
18772    }
18773
18774    #[tokio::test]
18775    async fn unrecoverable_indexed_gap_revokes_the_generation() {
18776        let asserter = Asserter::new();
18777        asserter.push_success(&100_u64);
18778        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
18779        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18780        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18781            provider,
18782            SubscriberMode::PubSub,
18783            SubscriberConfig {
18784                preconfirmations: PreconfirmationMode::Preferred,
18785                ..SubscriberConfig::default()
18786            },
18787        )
18788        .with_provider_ref(ProviderRef::new("base-paid", 7));
18789        subscriber.chain_id = Some(8_453);
18790
18791        subscriber
18792            .normalize_flashblock_event(base_flashblock_event(indexed_flashblock(
18793                B256::repeat_byte(0x41),
18794                B256::repeat_byte(0xa1),
18795            )))
18796            .await
18797            .expect("index zero preview");
18798        let BaseFlashblockWirePayload::Indexed(mut gap) =
18799            indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
18800        else {
18801            unreachable!()
18802        };
18803        gap.index = 2;
18804        gap.base = None;
18805        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18806        let event = subscriber
18807            .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed(
18808                gap,
18809            )))
18810            .await
18811            .expect("preferred mode fails closed without pending recovery")
18812            .expect("generation invalidation is observable");
18813        assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
18814        assert!(subscriber.latest_preconfirmation.is_none());
18815        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
18816    }
18817
18818    #[test]
18819    fn base_flashblock_wire_decodes_cumulative_block_shape() {
18820        let payload: BaseFlashblockWirePayload = serde_json::from_str(
18821            r#"{
18822                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
18823                "number":"0x2ef403b",
18824                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18825                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
18826                "timestamp":"0x6a68dd59",
18827                "transactions":[]
18828            }"#,
18829        )
18830        .expect("decode current Base newFlashblocks shape");
18831        let BaseFlashblockWirePayload::Block(payload) = payload else {
18832            panic!("expected cumulative block-shaped payload")
18833        };
18834        assert_eq!(payload.number, 49_233_979);
18835        assert_eq!(payload.timestamp, 1_785_257_305);
18836        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
18837        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
18838        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
18839    }
18840
18841    #[tokio::test]
18842    async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
18843        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18844        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18845            provider,
18846            SubscriberMode::PubSub,
18847            SubscriberConfig::default(),
18848        )
18849        .with_provider_ref(ProviderRef::new("base-paid", 7));
18850        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
18851            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
18852            local_matcher: None,
18853            route_key: None,
18854        })];
18855        subscriber.interests = subscriber.base_interests.clone();
18856
18857        let first: BaseFlashblockWirePayload = serde_json::from_str(
18858            r#"{
18859                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
18860                "number":"0x65",
18861                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
18862                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
18863                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
18864                "timestamp":"0x6553f165",
18865                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
18866            }"#,
18867        )
18868        .expect("decode first cumulative preview");
18869        subscriber
18870            .normalize_flashblock_event(base_flashblock_event(first))
18871            .await
18872            .expect("first preview is accepted");
18873
18874        let mut second_log = rpc_log(false);
18875        second_log.block_hash = Some(B256::ZERO);
18876        second_log.block_number = Some(102);
18877        second_log.block_timestamp = Some(1_700_000_102);
18878        second_log.transaction_hash = Some(B256::repeat_byte(0x42));
18879        second_log.transaction_index = Some(0);
18880        second_log.log_index = Some(0);
18881
18882        let pending_log_ingress = Instant::now() - Duration::from_millis(25);
18883        let before_preview = subscriber
18884            .normalize_flashblock_event(SubscriberEvent::BasePendingLogTimed {
18885                source_id: 0,
18886                log: second_log,
18887                timing: FlashblockIngressTiming::new(pending_log_ingress),
18888            })
18889            .await
18890            .expect("a zero-hash log for the next block must be buffered");
18891        assert!(before_preview.is_none());
18892
18893        let second: BaseFlashblockWirePayload = serde_json::from_str(
18894            r#"{
18895                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
18896                "number":"0x66",
18897                "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
18898                "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18899                "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
18900                "timestamp":"0x6553f166",
18901                "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
18902            }"#,
18903        )
18904        .expect("decode second cumulative preview");
18905        let event = subscriber
18906            .normalize_flashblock_event(base_flashblock_event(second))
18907            .await
18908            .expect("second preview is accepted")
18909            .expect("the matching buffered log is released");
18910        let SubscriberEvent::PreconfirmedLogs {
18911            flashblock,
18912            logs,
18913            timing,
18914        } = event
18915        else {
18916            panic!("expected a preconfirmed log batch")
18917        };
18918        assert_eq!(timing.source_ingress(), pending_log_ingress);
18919        assert_eq!(flashblock.block_number, 102);
18920        assert_ne!(flashblock.content_hash, B256::ZERO);
18921        assert_eq!(flashblock.partial_block_hash, None);
18922        assert_eq!(logs.len(), 1);
18923        assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
18924        assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
18925    }
18926
18927    #[test]
18928    fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
18929        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18930        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18931            provider,
18932            SubscriberMode::PubSub,
18933            SubscriberConfig {
18934                preconfirmations: PreconfirmationMode::Required,
18935                ..SubscriberConfig::default()
18936            },
18937        )
18938        .with_provider_ref(ProviderRef::new("base-paid", 7));
18939        subscriber.chain_id = Some(8_453);
18940        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
18941
18942        let sources = subscriber.pubsub_stream_sources();
18943        assert!(
18944            sources
18945                .iter()
18946                .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
18947        );
18948        assert!(
18949            !sources
18950                .iter()
18951                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
18952        );
18953    }
18954
18955    #[test]
18956    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
18957    fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() {
18958        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18959        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18960            provider,
18961            SubscriberMode::PubSub,
18962            SubscriberConfig {
18963                preconfirmations: PreconfirmationMode::Required,
18964                ..SubscriberConfig::default()
18965            },
18966        );
18967        subscriber
18968            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
18969            .expect("configure external source");
18970        subscriber.chain_id = Some(1);
18971        subscriber.base_interests = vec![
18972            ReactiveInterest::Blocks(BlockInterest::default()),
18973            log_interest_matching_rpc_log(),
18974        ];
18975        subscriber.interests = subscriber.base_interests.clone();
18976
18977        let pubsub = subscriber.pubsub_stream_sources();
18978        assert!(
18979            pubsub
18980                .iter()
18981                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
18982        );
18983        assert!(
18984            pubsub
18985                .iter()
18986                .any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. }))
18987        );
18988        assert!(pubsub.iter().all(|source| !matches!(
18989            source,
18990            SubscriberStreamSource::BaseFlashblocks
18991                | SubscriberStreamSource::BasePendingLog { .. }
18992                | SubscriberStreamSource::OpPendingFlashblocks
18993                | SubscriberStreamSource::CanonicalHeadPolling
18994        )));
18995        assert!(
18996            subscriber
18997                .polling_stream_sources()
18998                .iter()
18999                .all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) })
19000        );
19001        assert!(
19002            subscriber
19003                .capabilities()
19004                .supports(SubscriberCapability::Preconfirmations)
19005        );
19006    }
19007
19008    #[test]
19009    #[cfg(feature = "raw-flashblocks-json")]
19010    fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() {
19011        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19012        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19013            provider,
19014            SubscriberMode::PubSub,
19015            SubscriberConfig::default(),
19016        );
19017        subscriber
19018            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
19019            .expect("configure external source");
19020        subscriber.chain_id = Some(1);
19021
19022        assert!(matches!(
19023            subscriber.validate_flashblocks_setup(),
19024            Err(SubscriberError::InvalidConfig(message))
19025                if message.contains("require preconfirmations")
19026        ));
19027    }
19028
19029    #[tokio::test]
19030    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19031    async fn external_flashblocks_configuration_is_rejected_after_registration_starts() {
19032        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19033        let mut fresh = AlloySubscriber::<_, Ethereum>::new(
19034            provider,
19035            SubscriberMode::PubSub,
19036            SubscriberConfig {
19037                preconfirmations: PreconfirmationMode::Preferred,
19038                ..SubscriberConfig::default()
19039            },
19040        );
19041        fresh
19042            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
19043            .expect("construction-time external source");
19044
19045        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19046        let mut started = AlloySubscriber::<_, Ethereum>::new(
19047            provider,
19048            SubscriberMode::PubSub,
19049            SubscriberConfig {
19050                preconfirmations: PreconfirmationMode::Preferred,
19051                ..SubscriberConfig::default()
19052            },
19053        )
19054        .with_provider_ref(ProviderRef::new("canonical", 3));
19055        started.chain_id = Some(8_453);
19056        started
19057            .register_interests(&[log_interest_matching_rpc_log()])
19058            .await
19059            .expect("register canonical topology");
19060
19061        assert!(matches!(
19062            started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)),
19063            Err(SubscriberError::InvalidConfig(message))
19064                if message.contains("before subscriber registration")
19065        ));
19066    }
19067
19068    #[tokio::test]
19069    #[cfg(feature = "raw-flashblocks-json")]
19070    async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() {
19071        let asserter = Asserter::new();
19072        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19073        let source = ProviderRef::new("raw-json", 4);
19074        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19075            provider,
19076            SubscriberMode::PubSub,
19077            SubscriberConfig {
19078                preconfirmations: PreconfirmationMode::Required,
19079                ..SubscriberConfig::default()
19080            },
19081        );
19082        subscriber
19083            .configure_external_flashblock_updates(source.clone())
19084            .expect("configure external source");
19085        subscriber.chain_id = Some(1);
19086        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19087        subscriber.interests = subscriber.base_interests.clone();
19088        let desired = subscriber.pubsub_stream_sources();
19089        let mut streams = SubscriberStreams::new();
19090        for source in desired {
19091            streams.push(source, stream::pending().boxed());
19092        }
19093        subscriber.state = AlloySubscriberState::Active(streams);
19094        subscriber.sources_dirty = false;
19095
19096        let preflight = subscriber
19097            .establish_flashblocks_preflight(1)
19098            .await
19099            .expect("external source preflight");
19100        assert_eq!(preflight.provider(), &source);
19101        assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates);
19102        assert_eq!(preflight.pending_log_subscriptions(), 0);
19103        assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0);
19104        assert!(asserter.read_q().is_empty());
19105    }
19106
19107    #[tokio::test]
19108    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19109    async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream()
19110    {
19111        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19112        let source = ProviderRef::new("raw-json", 4);
19113        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19114            provider,
19115            SubscriberMode::PubSub,
19116            SubscriberConfig {
19117                preconfirmations: PreconfirmationMode::Preferred,
19118                ..SubscriberConfig::default()
19119            },
19120        );
19121        subscriber
19122            .configure_external_flashblock_updates(source.clone())
19123            .expect("configure external source");
19124        subscriber.chain_id = Some(1);
19125        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19126        subscriber.interests = subscriber.base_interests.clone();
19127        let filter = subscriber.log_stream_filters().remove(0);
19128        let source_id = subscriber.log_source_id(&filter);
19129        let mut streams = SubscriberStreams::new();
19130        streams.push(
19131            SubscriberStreamSource::PubSubLog {
19132                id: source_id,
19133                filter,
19134            },
19135            stream::pending().boxed(),
19136        );
19137        subscriber.state = AlloySubscriberState::Active(streams);
19138        subscriber.sources_dirty = false;
19139
19140        let sender = subscriber
19141            .open_external_flashblock_update_channel(2)
19142            .expect("bounded external queue");
19143        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19144        let update_stream = subscriber
19145            .connect_source_stream(external.clone())
19146            .await
19147            .expect("attach receiver as subscriber source");
19148        subscriber.install_source_stream(external, update_stream);
19149        subscriber.sources_dirty = false;
19150
19151        let mut adapter = RawJsonFlashblocksAdapter::new(source);
19152        let frame = br#"{
19153            "payload_id":"0x1111111111111111",
19154            "index":0,
19155            "base":{
19156                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
19157                "block_number":"0x7",
19158                "timestamp":"0x6553f107"
19159            },
19160            "diff":{
19161                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19162                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
19163                "transactions":["0x01"]
19164            },
19165            "metadata":{
19166                "block_number":7,
19167                "receipts":{
19168                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
19169                        "logs":[{
19170                            "address":"0x4242424242424242424242424242424242424242",
19171                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
19172                            "data":"0x"
19173                        }]
19174                    }
19175                }
19176            }
19177        }"#;
19178        let update = adapter
19179            .ingest_json(frame)
19180            .expect("valid raw update")
19181            .expect("snapshot update");
19182        let valid_update = update.clone();
19183        let sending = {
19184            let sender = sender.clone();
19185            tokio::spawn(async move { sender.send(update).await })
19186        };
19187
19188        let preview = subscriber
19189            .next_scoped_batch()
19190            .await
19191            .expect("poll preview")
19192            .expect("preview batch");
19193        assert_eq!(preview.records().len(), 1);
19194        assert!(preview.records()[0].scope().is_preconfirmed());
19195        assert_eq!(
19196            preview.records()[0].context.source,
19197            InputSource::Flashblocks
19198        );
19199        assert!(subscriber.latest_preconfirmation.is_some());
19200        assert_eq!(sending.await.expect("sender task"), Ok(()));
19201
19202        let mut invalid_update = valid_update.clone();
19203        let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else {
19204            unreachable!("fixture is a snapshot")
19205        };
19206        snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee));
19207        let rejecting = {
19208            let sender = sender.clone();
19209            tokio::spawn(async move { sender.send(invalid_update).await })
19210        };
19211        let rejected = subscriber
19212            .next_scoped_batch()
19213            .await
19214            .expect("preferred mode keeps polling")
19215            .expect("rejected update invalidation");
19216        assert!(rejected.preconfirmation_invalidated());
19217        assert!(rejected.records().is_empty());
19218        assert!(subscriber.latest_preconfirmation.is_none());
19219        assert_eq!(
19220            rejecting.await.expect("sender task"),
19221            Err(FlashblockUpdateChannelError::Rejected)
19222        );
19223        subscriber
19224            .ingest_flashblock_update(valid_update)
19225            .expect("rejected generation is ignored thereafter");
19226        assert!(subscriber.latest_preconfirmation.is_none());
19227
19228        let _reset = adapter
19229            .reset(ProviderRef::new("raw-json", 5))
19230            .expect("advance rejected source generation");
19231        let recovered_update = adapter
19232            .ingest_json(frame)
19233            .expect("valid replacement generation")
19234            .expect("replacement snapshot update");
19235        let recovering = {
19236            let sender = sender.clone();
19237            tokio::spawn(async move { sender.send(recovered_update).await })
19238        };
19239        let recovered = subscriber
19240            .next_scoped_batch()
19241            .await
19242            .expect("poll replacement generation")
19243            .expect("replacement preview batch");
19244        assert_eq!(recovered.records().len(), 1);
19245        assert!(matches!(
19246            &recovered.records()[0].context.chain_status,
19247            ChainStatus::Preconfirmed { flashblock }
19248                if flashblock.provider == ProviderRef::new("raw-json", 5)
19249        ));
19250        assert_eq!(recovering.await.expect("sender task"), Ok(()));
19251
19252        drop(sender);
19253        let invalidation = subscriber
19254            .next_scoped_batch()
19255            .await
19256            .expect("poll channel closure")
19257            .expect("closure invalidation");
19258        assert!(invalidation.preconfirmation_invalidated());
19259        assert!(invalidation.records().is_empty());
19260        assert!(subscriber.latest_preconfirmation.is_none());
19261        assert!(matches!(
19262            &subscriber.state,
19263            AlloySubscriberState::Active(streams)
19264                if streams.entries.iter().any(|entry| matches!(
19265                    entry.source,
19266                    SubscriberStreamSource::PubSubLog { id, .. } if id == source_id
19267                ))
19268        ));
19269    }
19270
19271    #[tokio::test]
19272    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19273    async fn required_external_channel_closure_fails_the_subscriber_closed() {
19274        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19275        let source = ProviderRef::new("raw-json", 4);
19276        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19277            provider,
19278            SubscriberMode::PubSub,
19279            SubscriberConfig {
19280                preconfirmations: PreconfirmationMode::Required,
19281                ..SubscriberConfig::default()
19282            },
19283        );
19284        subscriber
19285            .configure_external_flashblock_updates(source)
19286            .expect("configure external source");
19287        subscriber.chain_id = Some(1);
19288        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19289        subscriber.interests = subscriber.base_interests.clone();
19290        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19291        subscriber.sources_dirty = false;
19292
19293        let sender = subscriber
19294            .open_external_flashblock_update_channel(1)
19295            .expect("bounded external queue");
19296        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19297        let update_stream = subscriber
19298            .connect_source_stream(external.clone())
19299            .await
19300            .expect("attach receiver as subscriber source");
19301        subscriber.install_source_stream(external, update_stream);
19302        subscriber.sources_dirty = false;
19303        drop(sender);
19304
19305        assert!(matches!(
19306            subscriber.next_scoped_batch().await,
19307            Err(SubscriberError::Provider(ref message))
19308                if message.contains("required external Flashblock update channel closed")
19309        ));
19310    }
19311
19312    #[tokio::test]
19313    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19314    async fn required_external_channel_rejects_a_queued_malformed_update() {
19315        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19316        let source = ProviderRef::new("raw-json", 4);
19317        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19318            provider,
19319            SubscriberMode::PubSub,
19320            SubscriberConfig {
19321                preconfirmations: PreconfirmationMode::Required,
19322                ..SubscriberConfig::default()
19323            },
19324        );
19325        subscriber
19326            .configure_external_flashblock_updates(source.clone())
19327            .expect("configure external source");
19328        subscriber.chain_id = Some(1);
19329        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19330        subscriber.interests = subscriber.base_interests.clone();
19331        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19332        subscriber.sources_dirty = false;
19333
19334        let sender = subscriber
19335            .open_external_flashblock_update_channel(1)
19336            .expect("bounded external queue");
19337        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19338        let update_stream = subscriber
19339            .connect_source_stream(external.clone())
19340            .await
19341            .expect("attach receiver as subscriber source");
19342        subscriber.install_source_stream(external, update_stream);
19343        subscriber.sources_dirty = false;
19344
19345        let mut adapter = RawJsonFlashblocksAdapter::new(source);
19346        let mut update = adapter
19347            .ingest_json(
19348                br#"{
19349                    "payload_id":"0x1111111111111111",
19350                    "index":0,
19351                    "base":{
19352                        "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
19353                        "block_number":"0x7",
19354                        "timestamp":"0x6553f107"
19355                    },
19356                    "diff":{
19357                        "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19358                        "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
19359                        "transactions":[]
19360                    },
19361                    "metadata":{"block_number":7,"receipts":{}}
19362                }"#,
19363            )
19364            .expect("valid raw frame")
19365            .expect("snapshot update");
19366        let FlashblockUpdate::Snapshot(snapshot) = &mut update else {
19367            unreachable!("fixture is a snapshot")
19368        };
19369        snapshot.flashblock.content_hash = B256::ZERO;
19370        let sending = tokio::spawn(async move { sender.send(update).await });
19371
19372        assert!(matches!(
19373            subscriber.next_scoped_batch().await,
19374            Err(SubscriberError::Provider(ref message))
19375                if message.contains("content commitment is invalid")
19376        ));
19377        assert_eq!(
19378            sending.await.expect("sender task"),
19379            Err(FlashblockUpdateChannelError::Rejected)
19380        );
19381    }
19382
19383    #[tokio::test]
19384    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19385    async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() {
19386        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19387        let source = ProviderRef::new("raw-json", 4);
19388        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19389            provider,
19390            SubscriberMode::PubSub,
19391            SubscriberConfig {
19392                preconfirmations: PreconfirmationMode::Preferred,
19393                max_pending_records: 1,
19394                ..SubscriberConfig::default()
19395            },
19396        );
19397        subscriber
19398            .configure_external_flashblock_updates(source.clone())
19399            .expect("configure external source");
19400        subscriber.chain_id = Some(1);
19401        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19402        subscriber.interests = subscriber.base_interests.clone();
19403        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19404        subscriber.sources_dirty = false;
19405
19406        let sender = subscriber
19407            .open_external_flashblock_update_channel(1)
19408            .expect("bounded external queue");
19409        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19410        let update_stream = subscriber
19411            .connect_source_stream(external.clone())
19412            .await
19413            .expect("attach receiver as subscriber source");
19414        subscriber.install_source_stream(external, update_stream);
19415        subscriber.sources_dirty = false;
19416
19417        let first_frame = br#"{
19418            "payload_id":"0x1111111111111111",
19419            "index":0,
19420            "base":{
19421                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
19422                "block_number":"0x7",
19423                "timestamp":"0x6553f107"
19424            },
19425            "diff":{
19426                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19427                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
19428                "transactions":["0x01"]
19429            },
19430            "metadata":{
19431                "block_number":7,
19432                "receipts":{
19433                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
19434                        "logs":[{
19435                            "address":"0x4242424242424242424242424242424242424242",
19436                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
19437                            "data":"0x"
19438                        }]
19439                    }
19440                }
19441            }
19442        }"#;
19443        let second_frame = br#"{
19444            "payload_id":"0x1111111111111111",
19445            "index":1,
19446            "diff":{
19447                "state_root":"0xabababababababababababababababababababababababababababababababab",
19448                "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
19449                "transactions":["0x02"]
19450            },
19451            "metadata":{
19452                "block_number":7,
19453                "receipts":{
19454                    "0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{
19455                        "logs":[
19456                            {"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"},
19457                            {"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"}
19458                        ]
19459                    }
19460                }
19461            }
19462        }"#;
19463        let mut adapter = RawJsonFlashblocksAdapter::new(source);
19464        let first = adapter
19465            .ingest_json(first_frame)
19466            .expect("valid first frame")
19467            .expect("first snapshot");
19468        let first_send = {
19469            let sender = sender.clone();
19470            tokio::spawn(async move { sender.send(first).await })
19471        };
19472        let first_batch = subscriber
19473            .next_scoped_batch()
19474            .await
19475            .expect("poll first preview")
19476            .expect("first preview batch");
19477        assert_eq!(first_batch.records().len(), 1);
19478        assert_eq!(first_send.await.expect("sender task"), Ok(()));
19479
19480        let oversized = adapter
19481            .ingest_json(second_frame)
19482            .expect("valid oversized delta")
19483            .expect("oversized standardized snapshot");
19484        let rejected_send = {
19485            let sender = sender.clone();
19486            tokio::spawn(async move { sender.send(oversized).await })
19487        };
19488        let invalidation = subscriber
19489            .next_scoped_batch()
19490            .await
19491            .expect("poll capacity rejection")
19492            .expect("capacity invalidation batch");
19493        assert!(invalidation.preconfirmation_invalidated());
19494        assert_eq!(
19495            rejected_send.await.expect("sender task"),
19496            Err(FlashblockUpdateChannelError::Rejected)
19497        );
19498        assert_eq!(subscriber.rejected_external_flashblock_generation, None);
19499
19500        let _ = adapter
19501            .reset(ProviderRef::new("raw-json", 5))
19502            .expect("advance after local capacity rejection");
19503        let recovered = adapter
19504            .ingest_json(first_frame)
19505            .expect("valid recovered frame")
19506            .expect("recovered snapshot");
19507        let recovered_send = {
19508            let sender = sender.clone();
19509            tokio::spawn(async move { sender.send(recovered).await })
19510        };
19511        let recovered_batch = subscriber
19512            .next_scoped_batch()
19513            .await
19514            .expect("poll recovered generation")
19515            .expect("recovered preview batch");
19516        assert_eq!(recovered_batch.records().len(), 1);
19517        assert!(matches!(
19518            &recovered_batch.records()[0].context.chain_status,
19519            ChainStatus::Preconfirmed { flashblock }
19520                if flashblock.provider == ProviderRef::new("raw-json", 5)
19521        ));
19522        assert_eq!(recovered_send.await.expect("sender task"), Ok(()));
19523    }
19524
19525    #[tokio::test]
19526    async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
19527        let asserter = Asserter::new();
19528        let certified = rpc_block(101, B256::repeat_byte(0x65));
19529        asserter.push_success(&Some(certified.clone()));
19530        asserter.push_success(&Some(certified));
19531        asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
19532        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19533        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19534            provider,
19535            SubscriberMode::PubSub,
19536            SubscriberConfig::default(),
19537        );
19538
19539        assert!(matches!(
19540            subscriber
19541                .fetch_certified_canonical_head()
19542                .await
19543                .expect("first certified head"),
19544            Some(SubscriberEvent::BlockHeader(_))
19545        ));
19546        assert!(
19547            subscriber
19548                .fetch_certified_canonical_head()
19549                .await
19550                .expect("duplicate certified head")
19551                .is_none()
19552        );
19553        assert!(matches!(
19554            subscriber.fetch_certified_canonical_head().await,
19555            Err(SubscriberError::Provider(ref message))
19556                if message.contains("placeholder hash")
19557        ));
19558    }
19559
19560    #[tokio::test]
19561    async fn canonical_head_certification_times_out_a_silent_provider() {
19562        let provider =
19563            ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true));
19564        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19565            provider,
19566            SubscriberMode::PubSub,
19567            SubscriberConfig {
19568                preconfirmations: PreconfirmationMode::Required,
19569                canonical_head_request_timeout: Duration::from_millis(10),
19570                ..SubscriberConfig::default()
19571            },
19572        );
19573        subscriber.chain_id = Some(8_453);
19574
19575        let result = tokio::time::timeout(
19576            Duration::from_millis(100),
19577            subscriber.fetch_certified_canonical_head(),
19578        )
19579        .await
19580        .expect("subscriber must bound a silent provider request");
19581        assert!(matches!(
19582            result,
19583            Err(SubscriberError::Provider(ref message))
19584                if message.contains("canonical head certification timed out")
19585        ));
19586    }
19587
19588    #[tokio::test]
19589    async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
19590        let asserter = Asserter::new();
19591        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19592        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19593        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19594            provider,
19595            SubscriberMode::PubSub,
19596            SubscriberConfig {
19597                preconfirmations: PreconfirmationMode::Required,
19598                ..SubscriberConfig::default()
19599            },
19600        );
19601        subscriber.chain_id = Some(10);
19602        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
19603
19604        let event = subscriber
19605            .fetch_certified_canonical_head()
19606            .await
19607            .expect("OP pending parent can be certified")
19608            .expect("the first certified parent is emitted");
19609        let SubscriberEvent::BlockHeader(header) = event else {
19610            panic!("expected a certified canonical block header")
19611        };
19612        assert_eq!(header.number(), 100);
19613        assert_eq!(header.hash, B256::repeat_byte(0x64));
19614        assert_eq!(
19615            subscriber
19616                .flashblocks_rpc_metrics()
19617                .pending_block_requests(),
19618            1
19619        );
19620        assert_eq!(
19621            subscriber
19622                .flashblocks_rpc_metrics()
19623                .canonical_head_requests(),
19624            1
19625        );
19626        assert!(asserter.read_q().is_empty());
19627    }
19628
19629    #[test]
19630    fn optimism_uses_one_bounded_pending_state_stream() {
19631        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19632        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19633            provider,
19634            SubscriberMode::PubSub,
19635            SubscriberConfig {
19636                preconfirmations: PreconfirmationMode::Required,
19637                ..SubscriberConfig::default()
19638            },
19639        )
19640        .with_provider_ref(ProviderRef::new("op-paid", 11));
19641        subscriber.chain_id = Some(10);
19642        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
19643            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19644            local_matcher: None,
19645            route_key: None,
19646        })];
19647        subscriber.interests = subscriber.base_interests.clone();
19648
19649        let sources = subscriber.pubsub_stream_sources();
19650        assert_eq!(
19651            sources
19652                .iter()
19653                .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
19654                .count(),
19655            1
19656        );
19657        assert!(sources.iter().all(|source| !matches!(
19658            source,
19659            SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
19660        )));
19661    }
19662
19663    #[test]
19664    fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
19665        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19666        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19667            provider,
19668            SubscriberMode::PubSub,
19669            SubscriberConfig {
19670                preconfirmations: PreconfirmationMode::Required,
19671                ..SubscriberConfig::default()
19672            },
19673        );
19674        subscriber.chain_id = Some(10);
19675        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19676        subscriber.interests = subscriber.base_interests.clone();
19677
19678        // At 250 ms, the sampler reserves 4 * (exact parent + pending block +
19679        // one filtered log request) = 12 methods. The remaining 28 exact
19680        // receipt methods stay below the configured 40-method ceiling.
19681        assert_eq!(
19682            subscriber.pending_receipt_requests_per_second_capacity(),
19683            28
19684        );
19685        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
19686
19687        subscriber
19688            .interests
19689            .push(ReactiveInterest::Blocks(BlockInterest::default()));
19690        assert_eq!(
19691            subscriber.pending_receipt_requests_per_second_capacity(),
19692            24
19693        );
19694        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
19695        subscriber.interests.pop();
19696
19697        for _ in 0..4 {
19698            assert!(subscriber.reserve_flashblock_rpc_methods(3));
19699            assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
19700            assert!(subscriber.reserve_flashblock_rpc_methods(7));
19701        }
19702        assert!(!subscriber.reserve_flashblock_rpc_methods(1));
19703        subscriber.reset_flashblock_tracking();
19704        assert!(
19705            !subscriber.reserve_flashblock_rpc_methods(1),
19706            "a reconnect must not reset an endpoint's rolling quota window"
19707        );
19708    }
19709
19710    #[test]
19711    fn flashblocks_config_rejects_a_zero_rpc_budget() {
19712        let config = SubscriberConfig {
19713            preconfirmations: PreconfirmationMode::Required,
19714            max_flashblock_rpc_requests_per_second: 0,
19715            ..SubscriberConfig::default()
19716        };
19717
19718        assert!(matches!(
19719            validate_subscriber_config(&config),
19720            Err(SubscriberError::InvalidConfig(
19721                "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
19722            ))
19723        ));
19724    }
19725
19726    #[test]
19727    fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() {
19728        let config = SubscriberConfig {
19729            preconfirmations: PreconfirmationMode::Required,
19730            canonical_head_request_timeout: Duration::ZERO,
19731            ..SubscriberConfig::default()
19732        };
19733
19734        assert!(matches!(
19735            validate_subscriber_config(&config),
19736            Err(SubscriberError::InvalidConfig(
19737                "SubscriberConfig::canonical_head_request_timeout must be greater than zero"
19738            ))
19739        ));
19740    }
19741
19742    #[tokio::test]
19743    async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
19744        let asserter = Asserter::new();
19745        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
19746        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19747        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19748            provider,
19749            SubscriberMode::PubSub,
19750            SubscriberConfig {
19751                preconfirmations: PreconfirmationMode::Required,
19752                // Four ticks reserve three fixed methods each. Three remaining
19753                // methods cannot fund even one receipt on every tick.
19754                max_flashblock_rpc_requests_per_second: 15,
19755                ..SubscriberConfig::default()
19756            },
19757        )
19758        .with_provider_ref(ProviderRef::new("op-paid", 12));
19759        subscriber.chain_id = Some(10);
19760        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19761        subscriber.interests = subscriber.base_interests.clone();
19762        let desired = subscriber.pubsub_stream_sources();
19763        let mut streams = SubscriberStreams::new();
19764        for source in desired {
19765            streams.push(source, stream::pending().boxed());
19766        }
19767        subscriber.state = AlloySubscriberState::Active(streams);
19768        subscriber.sources_dirty = false;
19769        assert!(matches!(
19770            subscriber.establish_flashblocks_preflight(10).await,
19771            Err(SubscriberError::InvalidConfig(message))
19772                if message.contains("leaves no capacity for OP transaction receipts")
19773        ));
19774        assert!(asserter.read_q().is_empty());
19775    }
19776
19777    #[cfg(feature = "reactive-ws")]
19778    #[tokio::test]
19779    async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
19780        let asserter = Asserter::new();
19781        asserter.push_success(&serde_json::json!({"flashblocks": true}));
19782        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19783        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19784            provider,
19785            SubscriberMode::PubSub,
19786            SubscriberConfig {
19787                preconfirmations: PreconfirmationMode::Required,
19788                ..SubscriberConfig::default()
19789            },
19790        )
19791        .with_provider_ref(ProviderRef::new("base-paid", 7));
19792        subscriber.chain_id = Some(8_453);
19793        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19794        subscriber.interests = subscriber.base_interests.clone();
19795        let desired = subscriber.pubsub_stream_sources();
19796        let mut streams = SubscriberStreams::new();
19797        for source in desired {
19798            streams.push(source, stream::pending().boxed());
19799        }
19800        subscriber.state = AlloySubscriberState::Active(streams);
19801        subscriber.sources_dirty = false;
19802
19803        let preflight = subscriber
19804            .establish_flashblocks_preflight(8_453)
19805            .await
19806            .expect("preflight succeeds");
19807
19808        assert_eq!(preflight.chain_id(), 8_453);
19809        assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
19810        assert_eq!(
19811            preflight.delivery(),
19812            FlashblocksDelivery::NativeSubscriptions
19813        );
19814        assert_eq!(preflight.pending_log_subscriptions(), 1);
19815        assert_eq!(preflight.pending_log_filters(), 1);
19816        assert_eq!(
19817            preflight.advertised_capabilities(),
19818            Some(&serde_json::json!({"flashblocks": true}))
19819        );
19820    }
19821
19822    #[tokio::test]
19823    async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
19824        let asserter = Asserter::new();
19825        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
19826        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19827        asserter.push_success(&Vec::<Log>::new());
19828        asserter.push_success(&serde_json::json!([]));
19829        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19830        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19831            provider,
19832            SubscriberMode::PubSub,
19833            SubscriberConfig {
19834                preconfirmations: PreconfirmationMode::Required,
19835                ..SubscriberConfig::default()
19836            },
19837        )
19838        .with_provider_ref(ProviderRef::new("op-paid", 12));
19839        subscriber.chain_id = Some(10);
19840        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19841        subscriber.interests = subscriber.base_interests.clone();
19842        let desired = subscriber.pubsub_stream_sources();
19843        let mut streams = SubscriberStreams::new();
19844        for source in desired {
19845            streams.push(source, stream::pending().boxed());
19846        }
19847        subscriber.state = AlloySubscriberState::Active(streams);
19848        subscriber.sources_dirty = false;
19849
19850        let preflight = subscriber
19851            .establish_flashblocks_preflight(10)
19852            .await
19853            .expect("Optimism pending-state preflight succeeds");
19854
19855        assert_eq!(preflight.chain_id(), 10);
19856        assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
19857        assert_eq!(
19858            preflight.delivery(),
19859            FlashblocksDelivery::PendingStatePolling
19860        );
19861        assert_eq!(preflight.pending_log_subscriptions(), 0);
19862        assert_eq!(preflight.pending_log_filters(), 1);
19863        assert_eq!(
19864            preflight.advertised_capabilities(),
19865            Some(&serde_json::json!(["flashblocksv1"]))
19866        );
19867        assert!(asserter.read_q().is_empty());
19868    }
19869
19870    #[test]
19871    fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
19872        let transaction_hash = B256::repeat_byte(0x7e);
19873        let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
19874            .expect("serialize pending block fixture");
19875        value["transactions"] = serde_json::json!([{
19876            "type": "0x7e",
19877            "hash": transaction_hash,
19878            "sourceHash": B256::repeat_byte(0x11),
19879            "from": Address::repeat_byte(0x22),
19880            "to": Address::repeat_byte(0x33)
19881        }]);
19882
19883        let block = normalize_op_pending_block::<Ethereum>(value)
19884            .expect("OP-specific transaction bodies are reduced to hashes");
19885
19886        assert_eq!(
19887            block.transactions().as_hashes(),
19888            Some(&[transaction_hash][..])
19889        );
19890    }
19891
19892    #[tokio::test]
19893    async fn optimism_sampler_does_not_retry_malformed_pending_content() {
19894        let asserter = Asserter::new();
19895        let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
19896            .expect("serialize pending block fixture");
19897        pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
19898        asserter.push_success(&Some(pending));
19899        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19900        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19901            provider,
19902            SubscriberMode::PubSub,
19903            SubscriberConfig {
19904                preconfirmations: PreconfirmationMode::Required,
19905                ..SubscriberConfig::default()
19906            },
19907        )
19908        .with_provider_ref(ProviderRef::new("op-paid", 12));
19909        subscriber.chain_id = Some(10);
19910
19911        let error = match subscriber
19912            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19913            .await
19914        {
19915            Err(error) => error,
19916            Ok(_) => panic!("malformed provider content must fail immediately"),
19917        };
19918        assert!(
19919            error
19920                .to_string()
19921                .contains("transaction is missing its hash")
19922        );
19923        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
19924        assert!(asserter.read_q().is_empty());
19925    }
19926
19927    #[tokio::test]
19928    async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
19929        let asserter = Asserter::new();
19930        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19931        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19932        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19933            provider,
19934            SubscriberMode::PubSub,
19935            SubscriberConfig {
19936                preconfirmations: PreconfirmationMode::Required,
19937                ..SubscriberConfig::default()
19938            },
19939        )
19940        .with_provider_ref(ProviderRef::new("op-paid", 12));
19941        subscriber.chain_id = Some(10);
19942
19943        assert!(
19944            subscriber
19945                .fetch_pending_flashblock(None)
19946                .await
19947                .expect("the exact parent certifies the pending payload")
19948                .is_some()
19949        );
19950    }
19951
19952    #[tokio::test]
19953    async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
19954        let asserter = Asserter::new();
19955        asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
19956        asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
19957        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19958        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19959            provider,
19960            SubscriberMode::PubSub,
19961            SubscriberConfig {
19962                preconfirmations: PreconfirmationMode::Required,
19963                ..SubscriberConfig::default()
19964            },
19965        )
19966        .with_provider_ref(ProviderRef::new("op-paid", 12));
19967        subscriber.chain_id = Some(10);
19968
19969        assert!(matches!(
19970            subscriber.fetch_pending_flashblock(None).await,
19971            Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
19972                ref message
19973            ))) if message.contains("does not extend its exact certified parent")
19974        ));
19975        assert!(asserter.read_q().is_empty());
19976    }
19977
19978    #[tokio::test]
19979    async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
19980        let asserter = Asserter::new();
19981        let pending = rpc_block(101, B256::ZERO);
19982        queue_op_pending(&asserter, pending.clone());
19983        asserter.push_success(&Vec::<Log>::new());
19984        queue_op_pending(&asserter, pending);
19985        asserter.push_success(&Vec::<Log>::new());
19986        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19987        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19988            provider,
19989            SubscriberMode::PubSub,
19990            SubscriberConfig {
19991                preconfirmations: PreconfirmationMode::Required,
19992                ..SubscriberConfig::default()
19993            },
19994        )
19995        .with_provider_ref(ProviderRef::new("op-paid", 12));
19996        subscriber.chain_id = Some(10);
19997        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19998        subscriber.interests = subscriber.base_interests.clone();
19999
20000        assert!(
20001            subscriber
20002                .fetch_pending_flashblock(None)
20003                .await
20004                .expect("first cumulative pending view")
20005                .is_some()
20006        );
20007        assert!(
20008            subscriber
20009                .fetch_pending_flashblock(None)
20010                .await
20011                .expect("duplicate cumulative pending view")
20012                .is_none()
20013        );
20014
20015        assert_eq!(
20016            subscriber.flashblocks_rpc_metrics(),
20017            FlashblocksRpcMetrics {
20018                capability_requests: 0,
20019                provider_pair_chain_requests: 0,
20020                canonical_head_requests: 2,
20021                pending_block_requests: 2,
20022                pending_log_requests: 2,
20023                pending_receipt_requests: 0,
20024                pending_receipts_completed: 0,
20025                pending_receipts_unavailable: 0,
20026                failed_requests: 0,
20027                raced_samples: 0,
20028            }
20029        );
20030        assert!(asserter.read_q().is_empty());
20031    }
20032
20033    #[tokio::test]
20034    async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
20035        let asserter = Asserter::new();
20036        let transaction = B256::repeat_byte(0x42);
20037        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20038            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
20039        );
20040        let mut log = rpc_log(false);
20041        log.block_number = Some(101);
20042        log.block_hash = Some(B256::repeat_byte(0xa2));
20043        log.transaction_hash = Some(transaction);
20044        log.transaction_index = Some(0);
20045        log.log_index = Some(0);
20046        queue_op_pending(&asserter, pending.clone());
20047        asserter.push_success(&Vec::<Log>::new());
20048        asserter.push_success(&serde_json::Value::Null);
20049        queue_op_pending(&asserter, pending);
20050        asserter.push_success(&vec![log]);
20051        asserter.push_success(&serde_json::Value::Null);
20052        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20053        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20054            provider,
20055            SubscriberMode::PubSub,
20056            SubscriberConfig {
20057                preconfirmations: PreconfirmationMode::Required,
20058                ..SubscriberConfig::default()
20059            },
20060        )
20061        .with_provider_ref(ProviderRef::new("op-paid", 12));
20062        subscriber.chain_id = Some(10);
20063        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20064        subscriber.interests = subscriber.base_interests.clone();
20065
20066        assert!(matches!(
20067            subscriber
20068                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20069                .await
20070                .expect("first pending view is coherent"),
20071            Some(SubscriberEvent::FlashblockObserved)
20072        ));
20073        assert!(matches!(
20074            subscriber
20075                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20076                .await
20077                .expect("the unchanged view is checked again for lagging logs"),
20078            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20079        ));
20080        assert!(asserter.read_q().is_empty());
20081    }
20082
20083    #[tokio::test]
20084    async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
20085        let asserter = Asserter::new();
20086        let transaction = B256::repeat_byte(0x42);
20087        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20088            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
20089        );
20090        let mut log = rpc_log(false);
20091        log.block_number = Some(101);
20092        log.block_hash = Some(B256::repeat_byte(0xa2));
20093        log.transaction_hash = Some(transaction);
20094        log.transaction_index = Some(0);
20095        log.log_index = Some(0);
20096        queue_op_pending(&asserter, pending);
20097        asserter.push_success(&Vec::<Log>::new());
20098        asserter.push_success(&serde_json::json!({
20099            "transactionHash": transaction,
20100            "logs": [log]
20101        }));
20102        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20103        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20104            provider,
20105            SubscriberMode::PubSub,
20106            SubscriberConfig {
20107                preconfirmations: PreconfirmationMode::Required,
20108                ..SubscriberConfig::default()
20109            },
20110        )
20111        .with_provider_ref(ProviderRef::new("op-paid", 12));
20112        subscriber.chain_id = Some(10);
20113        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20114        subscriber.interests = subscriber.base_interests.clone();
20115
20116        let event = subscriber
20117            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20118            .await
20119            .expect("pending receipt fallback succeeds");
20120        assert!(matches!(
20121            event,
20122            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20123        ));
20124        assert_eq!(
20125            subscriber
20126                .flashblocks_rpc_metrics()
20127                .pending_receipt_requests(),
20128            1
20129        );
20130        assert!(asserter.read_q().is_empty());
20131    }
20132
20133    #[tokio::test]
20134    async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
20135        let asserter = Asserter::new();
20136        let transaction_a = B256::repeat_byte(0x41);
20137        let transaction_b = B256::repeat_byte(0x42);
20138        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20139            alloy_network::primitives::BlockTransactions::Hashes(vec![
20140                transaction_a,
20141                transaction_b,
20142            ]),
20143        );
20144        let mut log = rpc_log(false);
20145        log.block_number = Some(101);
20146        log.transaction_hash = Some(transaction_b);
20147        log.transaction_index = Some(1);
20148        queue_op_pending(&asserter, pending.clone());
20149        asserter.push_success(&Vec::<Log>::new());
20150        asserter.push_success(&serde_json::json!({
20151            "transactionHash": transaction_a,
20152            "logs": []
20153        }));
20154        queue_op_pending(&asserter, pending);
20155        asserter.push_success(&Vec::<Log>::new());
20156        asserter.push_success(&serde_json::json!({
20157            "transactionHash": transaction_b,
20158            "logs": [log]
20159        }));
20160        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20161        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20162            provider,
20163            SubscriberMode::PubSub,
20164            SubscriberConfig {
20165                preconfirmations: PreconfirmationMode::Required,
20166                max_pending_transaction_receipts_per_tick: 1,
20167                ..SubscriberConfig::default()
20168            },
20169        )
20170        .with_provider_ref(ProviderRef::new("op-paid", 12));
20171        subscriber.chain_id = Some(10);
20172        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20173        subscriber.interests = subscriber.base_interests.clone();
20174
20175        assert!(matches!(
20176            subscriber
20177                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20178                .await
20179                .expect("the first bounded receipt is hydrated"),
20180            Some(SubscriberEvent::FlashblockObserved)
20181        ));
20182        assert!(matches!(
20183            subscriber
20184                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20185                .await
20186                .expect("the remaining receipt is hydrated on the next tick"),
20187            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20188        ));
20189        assert_eq!(
20190            subscriber
20191                .flashblocks_rpc_metrics()
20192                .pending_receipt_requests(),
20193            2
20194        );
20195        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
20196        assert!(asserter.read_q().is_empty());
20197    }
20198
20199    #[tokio::test]
20200    async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
20201        let asserter = Asserter::new();
20202        let transaction_a = B256::repeat_byte(0x41);
20203        let transaction_b = B256::repeat_byte(0x42);
20204        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20205            alloy_network::primitives::BlockTransactions::Hashes(vec![
20206                transaction_a,
20207                transaction_b,
20208            ]),
20209        );
20210        let mut log = rpc_log(false);
20211        log.block_number = Some(101);
20212        log.transaction_hash = Some(transaction_b);
20213        log.transaction_index = Some(1);
20214        queue_op_pending(&asserter, pending.clone());
20215        asserter.push_success(&Vec::<Log>::new());
20216        asserter.push_success(&serde_json::Value::Null);
20217        queue_op_pending(&asserter, pending);
20218        asserter.push_success(&Vec::<Log>::new());
20219        asserter.push_success(&serde_json::json!({
20220            "transactionHash": transaction_b,
20221            "logs": [log]
20222        }));
20223        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20224        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20225            provider,
20226            SubscriberMode::PubSub,
20227            SubscriberConfig {
20228                preconfirmations: PreconfirmationMode::Required,
20229                max_pending_transaction_receipts_per_tick: 1,
20230                ..SubscriberConfig::default()
20231            },
20232        )
20233        .with_provider_ref(ProviderRef::new("op-paid", 12));
20234        subscriber.chain_id = Some(10);
20235        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20236        subscriber.interests = subscriber.base_interests.clone();
20237
20238        assert!(matches!(
20239            subscriber
20240                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20241                .await
20242                .expect("the first null receipt remains retryable"),
20243            Some(SubscriberEvent::FlashblockObserved)
20244        ));
20245        assert!(matches!(
20246            subscriber
20247                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20248                .await
20249                .expect("the next unattempted receipt is not starved"),
20250            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20251        ));
20252        assert!(
20253            subscriber
20254                .preconfirmed_unavailable_receipts
20255                .contains(&transaction_a)
20256        );
20257        assert!(
20258            subscriber
20259                .preconfirmed_receipted_transactions
20260                .contains(&transaction_b)
20261        );
20262        assert!(asserter.read_q().is_empty());
20263    }
20264
20265    #[tokio::test]
20266    async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
20267        let asserter = Asserter::new();
20268        let transaction_a = B256::repeat_byte(0x41);
20269        let transaction_b = B256::repeat_byte(0x42);
20270        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20271            alloy_network::primitives::BlockTransactions::Hashes(vec![
20272                transaction_a,
20273                transaction_b,
20274            ]),
20275        );
20276        let mut log = rpc_log(false);
20277        log.block_number = Some(101);
20278        log.transaction_hash = Some(transaction_b);
20279        log.transaction_index = Some(1);
20280        queue_op_pending(&asserter, pending.clone());
20281        asserter.push_success(&Vec::<Log>::new());
20282        asserter.push_success(&serde_json::json!({
20283            "transactionHash": transaction_a,
20284            "logs": []
20285        }));
20286        asserter.push_failure_msg("receipt temporarily unavailable");
20287        queue_op_pending(&asserter, pending);
20288        asserter.push_success(&Vec::<Log>::new());
20289        asserter.push_success(&serde_json::json!({
20290            "transactionHash": transaction_a,
20291            "logs": []
20292        }));
20293        asserter.push_success(&serde_json::json!({
20294            "transactionHash": transaction_b,
20295            "logs": [log]
20296        }));
20297        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20298        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20299            provider,
20300            SubscriberMode::PubSub,
20301            SubscriberConfig {
20302                preconfirmations: PreconfirmationMode::Required,
20303                max_pending_transaction_receipts_per_tick: 2,
20304                max_consecutive_flashblock_poll_failures: 2,
20305                ..SubscriberConfig::default()
20306            },
20307        )
20308        .with_provider_ref(ProviderRef::new("op-paid", 12));
20309        subscriber.chain_id = Some(10);
20310        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20311        subscriber.interests = subscriber.base_interests.clone();
20312
20313        assert!(
20314            subscriber
20315                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20316                .await
20317                .expect("one failed receipt response remains retryable")
20318                .is_none()
20319        );
20320        assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
20321        assert!(matches!(
20322            subscriber
20323                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20324                .await
20325                .expect("the complete batch is retried transactionally"),
20326            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20327        ));
20328        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
20329        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
20330        assert_eq!(
20331            subscriber
20332                .flashblocks_rpc_metrics()
20333                .pending_receipt_requests(),
20334            4
20335        );
20336        assert!(asserter.read_q().is_empty());
20337    }
20338
20339    #[tokio::test]
20340    async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
20341        let asserter = Asserter::new();
20342        let sampled_transaction = B256::repeat_byte(0x41);
20343        let advanced_transaction = B256::repeat_byte(0x42);
20344        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20345            alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
20346        );
20347        let mut log = rpc_log(false);
20348        log.block_number = Some(101);
20349        log.transaction_hash = Some(advanced_transaction);
20350        queue_op_pending(&asserter, pending);
20351        asserter.push_success(&Vec::<Log>::new());
20352        asserter.push_success(&serde_json::json!({
20353            "transactionHash": advanced_transaction,
20354            "logs": [log]
20355        }));
20356        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20357        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20358            provider,
20359            SubscriberMode::PubSub,
20360            SubscriberConfig {
20361                preconfirmations: PreconfirmationMode::Required,
20362                ..SubscriberConfig::default()
20363            },
20364        )
20365        .with_provider_ref(ProviderRef::new("op-paid", 12));
20366        subscriber.chain_id = Some(10);
20367        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20368        subscriber.interests = subscriber.base_interests.clone();
20369
20370        let error = match subscriber
20371            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20372            .await
20373        {
20374            Err(error) => error,
20375            Ok(_) => panic!("a receipt for another transaction must fail closed"),
20376        };
20377        assert!(
20378            error
20379                .to_string()
20380                .contains("hash disagrees with its request")
20381        );
20382        assert!(asserter.read_q().is_empty());
20383    }
20384
20385    #[tokio::test]
20386    async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
20387        let asserter = Asserter::new();
20388        let transaction_a = B256::repeat_byte(0x41);
20389        let transaction_b = B256::repeat_byte(0x42);
20390        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20391            alloy_network::primitives::BlockTransactions::Hashes(vec![
20392                transaction_a,
20393                transaction_b,
20394            ]),
20395        );
20396        let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
20397            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
20398        );
20399        queue_op_pending(&asserter, first);
20400        asserter.push_success(&Vec::<Log>::new());
20401        asserter.push_success(&serde_json::Value::Null);
20402        asserter.push_success(&serde_json::Value::Null);
20403        queue_op_pending(&asserter, regressive.clone());
20404        queue_op_pending(&asserter, regressive);
20405        asserter.push_success(&Vec::<Log>::new());
20406        asserter.push_success(&serde_json::Value::Null);
20407        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20408        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20409            provider,
20410            SubscriberMode::PubSub,
20411            SubscriberConfig {
20412                preconfirmations: PreconfirmationMode::Required,
20413                ..SubscriberConfig::default()
20414            },
20415        )
20416        .with_provider_ref(ProviderRef::new("op-paid", 12));
20417        subscriber.chain_id = Some(10);
20418        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20419        subscriber.interests = subscriber.base_interests.clone();
20420
20421        assert!(
20422            subscriber
20423                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20424                .await
20425                .expect("first pending view is coherent")
20426                .is_some()
20427        );
20428        assert!(matches!(
20429            subscriber
20430                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20431                .await
20432                .expect("regression revokes instead of terminating the stream"),
20433            Some(SubscriberEvent::FlashblockInvalidated)
20434        ));
20435        assert!(subscriber.latest_preconfirmation.is_none());
20436        assert!(subscriber.pending_preconfirmation_invalidation);
20437        assert!(
20438            subscriber
20439                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20440                .await
20441                .expect("a later coherent view establishes a fresh snapshot")
20442                .is_some()
20443        );
20444        assert!(subscriber.latest_preconfirmation.is_some());
20445        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
20446        assert!(asserter.read_q().is_empty());
20447    }
20448
20449    #[tokio::test]
20450    async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
20451        let asserter = Asserter::new();
20452        let first = rpc_block(101, B256::repeat_byte(0xa1));
20453        let second = rpc_block(102, B256::repeat_byte(0xa2));
20454        queue_op_pending(&asserter, first);
20455        asserter.push_success(&Vec::<Log>::new());
20456        queue_op_pending(&asserter, second);
20457        asserter.push_success(&Vec::<Log>::new());
20458        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20459        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20460            provider,
20461            SubscriberMode::PubSub,
20462            SubscriberConfig {
20463                preconfirmations: PreconfirmationMode::Required,
20464                ..SubscriberConfig::default()
20465            },
20466        )
20467        .with_provider_ref(ProviderRef::new("op-paid", 12));
20468        subscriber.chain_id = Some(10);
20469        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20470        subscriber.interests = subscriber.base_interests.clone();
20471
20472        subscriber
20473            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20474            .await
20475            .expect("first quiet payload is observed");
20476        assert!(!subscriber.pending_preconfirmation_invalidation);
20477        subscriber
20478            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20479            .await
20480            .expect("replacement quiet payload is observed");
20481        assert!(subscriber.pending_preconfirmation_invalidation);
20482        assert_eq!(
20483            subscriber
20484                .latest_preconfirmation
20485                .as_ref()
20486                .map(|flashblock| flashblock.block_number),
20487            Some(102)
20488        );
20489        assert!(asserter.read_q().is_empty());
20490    }
20491
20492    #[tokio::test]
20493    async fn optimism_sampler_rejects_malformed_pending_receipts() {
20494        let asserter = Asserter::new();
20495        let transaction = B256::repeat_byte(0x42);
20496        let pending = rpc_block(101, B256::ZERO).with_transactions(
20497            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
20498        );
20499        queue_op_pending(&asserter, pending);
20500        asserter.push_success(&Vec::<Log>::new());
20501        asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
20502        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20503        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20504            provider,
20505            SubscriberMode::PubSub,
20506            SubscriberConfig {
20507                preconfirmations: PreconfirmationMode::Required,
20508                ..SubscriberConfig::default()
20509            },
20510        )
20511        .with_provider_ref(ProviderRef::new("op-paid", 12));
20512        subscriber.chain_id = Some(10);
20513        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20514        subscriber.interests = subscriber.base_interests.clone();
20515
20516        let error = match subscriber
20517            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20518            .await
20519        {
20520            Err(error) => error,
20521            Ok(_) => panic!("malformed receipt content must fail closed"),
20522        };
20523        assert!(error.to_string().contains("missing its log array"));
20524        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
20525        assert!(asserter.read_q().is_empty());
20526    }
20527
20528    #[tokio::test]
20529    async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
20530        let asserter = Asserter::new();
20531        let transaction_a = B256::repeat_byte(0x41);
20532        let transaction_b = B256::repeat_byte(0x42);
20533        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20534            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
20535        );
20536        let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
20537            alloy_network::primitives::BlockTransactions::Hashes(vec![
20538                transaction_a,
20539                transaction_b,
20540            ]),
20541        );
20542        let mut log = rpc_log(false);
20543        log.block_number = Some(101);
20544        log.block_hash = Some(B256::repeat_byte(0xa2));
20545        log.transaction_hash = Some(transaction_b);
20546        log.transaction_index = Some(1);
20547        log.log_index = Some(0);
20548        queue_op_pending(&asserter, first);
20549        asserter.push_success(&vec![log.clone()]);
20550        asserter.push_success(&serde_json::Value::Null);
20551        queue_op_pending(&asserter, second);
20552        asserter.push_success(&vec![log.clone()]);
20553        asserter.push_success(&serde_json::Value::Null);
20554        asserter.push_success(&serde_json::json!({
20555            "transactionHash": transaction_b,
20556            "logs": [log]
20557        }));
20558        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20559        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20560            provider,
20561            SubscriberMode::PubSub,
20562            SubscriberConfig {
20563                preconfirmations: PreconfirmationMode::Required,
20564                ..SubscriberConfig::default()
20565            },
20566        )
20567        .with_provider_ref(ProviderRef::new("op-paid", 12));
20568        subscriber.chain_id = Some(10);
20569        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20570        subscriber.interests = subscriber.base_interests.clone();
20571
20572        assert!(
20573            subscriber
20574                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20575                .await
20576                .expect("a cross-request race remains retryable")
20577                .is_none()
20578        );
20579        assert!(
20580            subscriber
20581                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20582                .await
20583                .expect("the next coherent cumulative view is delivered")
20584                .is_some()
20585        );
20586        assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
20587        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
20588        assert!(asserter.read_q().is_empty());
20589    }
20590
20591    #[tokio::test]
20592    async fn optimism_sampler_uses_the_paired_pending_state_provider() {
20593        let stream_asserter = Asserter::new();
20594        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20595        let state_asserter = Asserter::new();
20596        queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
20597        state_asserter.push_success(&Vec::<Log>::new());
20598        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
20599        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20600            stream_provider,
20601            SubscriberMode::PubSub,
20602            SubscriberConfig {
20603                preconfirmations: PreconfirmationMode::Required,
20604                ..SubscriberConfig::default()
20605            },
20606        )
20607        .with_provider_ref(ProviderRef::new("op-paid", 12))
20608        .with_flashblocks_state_provider(state_provider);
20609        subscriber.chain_id = Some(10);
20610        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20611        subscriber.interests = subscriber.base_interests.clone();
20612
20613        assert!(
20614            subscriber
20615                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20616                .await
20617                .expect("paired pending-state reads succeed")
20618                .is_some()
20619        );
20620        assert!(state_asserter.read_q().is_empty());
20621        assert!(stream_asserter.read_q().is_empty());
20622    }
20623
20624    #[tokio::test]
20625    async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
20626        let asserter = Asserter::new();
20627        let pending = rpc_block(101, B256::ZERO);
20628        queue_op_pending(&asserter, pending.clone());
20629        asserter.push_failure_msg("temporarily unavailable");
20630        queue_op_pending(&asserter, pending);
20631        asserter.push_success(&Vec::<Log>::new());
20632        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20633        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20634            provider,
20635            SubscriberMode::PubSub,
20636            SubscriberConfig {
20637                preconfirmations: PreconfirmationMode::Required,
20638                max_consecutive_flashblock_poll_failures: 2,
20639                ..SubscriberConfig::default()
20640            },
20641        )
20642        .with_provider_ref(ProviderRef::new("op-paid", 12));
20643        subscriber.chain_id = Some(10);
20644        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20645        subscriber.interests = subscriber.base_interests.clone();
20646
20647        assert!(
20648            subscriber
20649                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20650                .await
20651                .expect("one request failure stays retryable")
20652                .is_none()
20653        );
20654        assert!(
20655            subscriber
20656                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20657                .await
20658                .expect("the next cumulative view retries the missing logs")
20659                .is_some()
20660        );
20661        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
20662        assert!(asserter.read_q().is_empty());
20663    }
20664
20665    #[tokio::test]
20666    async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
20667        let asserter = Asserter::new();
20668        asserter.push_failure_msg("temporarily unavailable");
20669        asserter.push_failure_msg("still unavailable");
20670        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20671        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20672            provider,
20673            SubscriberMode::PubSub,
20674            SubscriberConfig {
20675                preconfirmations: PreconfirmationMode::Required,
20676                max_consecutive_flashblock_poll_failures: 2,
20677                ..SubscriberConfig::default()
20678            },
20679        )
20680        .with_provider_ref(ProviderRef::new("op-paid", 12));
20681        subscriber.chain_id = Some(10);
20682
20683        assert!(
20684            subscriber
20685                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20686                .await
20687                .expect("the first request failure stays retryable")
20688                .is_none()
20689        );
20690        let error = match subscriber
20691            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20692            .await
20693        {
20694            Err(error) => error,
20695            Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
20696        };
20697        assert!(error.to_string().contains("still unavailable"));
20698        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
20699        assert!(asserter.read_q().is_empty());
20700    }
20701
20702    #[tokio::test]
20703    async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
20704        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20705        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20706            provider,
20707            SubscriberMode::PubSub,
20708            SubscriberConfig {
20709                preconfirmations: PreconfirmationMode::Required,
20710                ..SubscriberConfig::default()
20711            },
20712        )
20713        .with_provider_ref(ProviderRef::new("wrong-chain", 1));
20714        subscriber.chain_id = Some(10);
20715        subscriber.interests = vec![log_interest_matching_rpc_log()];
20716
20717        assert!(matches!(
20718            subscriber.establish_flashblocks_preflight(8_453).await,
20719            Err(SubscriberError::ChainMismatch {
20720                expected: 8_453,
20721                actual: 10
20722            })
20723        ));
20724    }
20725
20726    #[tokio::test]
20727    async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
20728        let stream_asserter = Asserter::new();
20729        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20730        let state_asserter = Asserter::new();
20731        state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
20732        state_asserter.push_success(&8_453_u64);
20733        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
20734        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20735            stream_provider,
20736            SubscriberMode::PubSub,
20737            SubscriberConfig {
20738                preconfirmations: PreconfirmationMode::Required,
20739                ..SubscriberConfig::default()
20740            },
20741        )
20742        .with_provider_ref(ProviderRef::new("op-paid", 12))
20743        .with_flashblocks_state_provider(state_provider);
20744        subscriber.chain_id = Some(10);
20745        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20746        subscriber.interests = subscriber.base_interests.clone();
20747        let desired = subscriber.pubsub_stream_sources();
20748        let mut streams = SubscriberStreams::new();
20749        for source in desired {
20750            streams.push(source, stream::pending().boxed());
20751        }
20752        subscriber.state = AlloySubscriberState::Active(streams);
20753        subscriber.sources_dirty = false;
20754        assert!(matches!(
20755            subscriber.establish_flashblocks_preflight(10).await,
20756            Err(SubscriberError::ChainMismatch {
20757                expected: 10,
20758                actual: 8_453
20759            })
20760        ));
20761        assert!(state_asserter.read_q().is_empty());
20762        assert!(stream_asserter.read_q().is_empty());
20763    }
20764
20765    #[test]
20766    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
20767        let parent = BlockRef {
20768            number: 79,
20769            hash: B256::repeat_byte(0x79),
20770            parent_hash: Some(B256::repeat_byte(0x78)),
20771            timestamp: Some(1_700_000_079),
20772        };
20773        let old_tip = BlockRef {
20774            number: 80,
20775            hash: B256::repeat_byte(0x80),
20776            parent_hash: Some(parent.hash),
20777            timestamp: Some(1_700_000_080),
20778        };
20779        let replacement = BlockRef {
20780            hash: B256::repeat_byte(0xe0),
20781            parent_hash: Some(B256::repeat_byte(0xdf)),
20782            ..old_tip
20783        };
20784        let mut state =
20785            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
20786
20787        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
20788            .expect("replacement metadata is structurally valid")
20789            .expect("unknown parent is an observable rewind");
20790
20791        assert_eq!(rewind.common_ancestor, None);
20792        assert_eq!(rewind.dropped, vec![parent, old_tip]);
20793        assert_eq!(state.retained_canonical_history(), &[replacement]);
20794        assert_eq!(state.coverage_head(), Some(&replacement));
20795        assert_eq!(state.safe_head(), None);
20796        assert_eq!(state.finalized_head(), None);
20797    }
20798
20799    #[test]
20800    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
20801        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
20802        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
20803        let encoded = serde_json::to_string(&valid).expect("serialize id");
20804        assert_eq!(
20805            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
20806            valid
20807        );
20808        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
20809    }
20810
20811    fn rpc_log(removed: bool) -> Log {
20812        Log {
20813            inner: alloy_primitives::Log::new_unchecked(
20814                Address::repeat_byte(0x42),
20815                vec![B256::repeat_byte(0x01)],
20816                Bytes::new(),
20817            ),
20818            block_hash: Some(B256::repeat_byte(0x02)),
20819            block_number: Some(7),
20820            block_timestamp: Some(1_700_000_000),
20821            transaction_hash: Some(B256::repeat_byte(0x03)),
20822            transaction_index: Some(4),
20823            log_index: Some(5),
20824            removed,
20825        }
20826    }
20827
20828    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
20829        use alloy_consensus::SignableTransaction as _;
20830
20831        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
20832            chain_id,
20833            ..Default::default()
20834        }
20835        .into_signed(alloy_primitives::Signature::test_signature())
20836        .into();
20837        alloy_rpc_types_eth::Transaction {
20838            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
20839            block_hash: None,
20840            block_number: None,
20841            transaction_index: None,
20842            effective_gas_price: None,
20843        }
20844    }
20845
20846    #[cfg(feature = "reactive-ws")]
20847    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
20848        Log {
20849            inner: alloy_primitives::Log::new_unchecked(
20850                Address::repeat_byte(0x42),
20851                vec![B256::repeat_byte(0x01)],
20852                Bytes::new(),
20853            ),
20854            block_hash: Some(B256::repeat_byte(block_number as u8)),
20855            block_number: Some(block_number),
20856            block_timestamp: Some(1_700_000_000 + block_number),
20857            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
20858            transaction_index: Some(transaction_index),
20859            log_index: Some(log_index),
20860            removed: false,
20861        }
20862    }
20863
20864    #[cfg(any(
20865        feature = "raw-flashblocks-json",
20866        feature = "reactive-polling",
20867        feature = "reactive-ws"
20868    ))]
20869    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
20870        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
20871            hash,
20872            inner: alloy_consensus::Header {
20873                number,
20874                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
20875                timestamp: 1_700_000_000 + number,
20876                ..Default::default()
20877            },
20878            total_difficulty: None,
20879            size: None,
20880        })
20881    }
20882
20883    fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
20884        let parent = rpc_block(
20885            pending.header().number().saturating_sub(1),
20886            pending.header().parent_hash(),
20887        );
20888        asserter.push_success(&Some(pending));
20889        asserter.push_success(&Some(parent));
20890    }
20891
20892    #[tokio::test(flavor = "multi_thread")]
20893    #[cfg(feature = "reactive-ws")]
20894    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
20895        let stream_asserter = Asserter::new();
20896        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20897        let verification_asserter = Asserter::new();
20898        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
20899        let verification_provider =
20900            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
20901        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20902            provider,
20903            SubscriberMode::PubSub,
20904            SubscriberConfig {
20905                verify_log_block_context: true,
20906                ..SubscriberConfig::default()
20907            },
20908        )
20909        .with_log_verification_provider(verification_provider);
20910        let log = rpc_log_at(7, 0, 0);
20911
20912        subscriber
20913            .verify_log_block_context(&log)
20914            .await
20915            .expect("verify live log block");
20916        subscriber
20917            .verify_log_block_context(&log)
20918            .await
20919            .expect("reuse verified block cache");
20920        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
20921
20922        assert_eq!(
20923            record.context.block.expect("verified block").parent_hash,
20924            Some(B256::repeat_byte(6))
20925        );
20926        assert!(
20927            verification_asserter.read_q().is_empty(),
20928            "one provider lookup should verify every log in the same block"
20929        );
20930        assert!(
20931            stream_asserter.read_q().is_empty(),
20932            "verification must not use the high-volume stream provider"
20933        );
20934    }
20935
20936    #[tokio::test(flavor = "multi_thread")]
20937    async fn stream_with_termination_yields_terminal_source_marker() {
20938        let mut stream = stream_with_termination::<Ethereum, _>(
20939            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
20940                0xaa,
20941            ))]),
20942            SubscriberStreamSource::PubSubPendingHashes,
20943        );
20944
20945        assert!(matches!(
20946            stream.next().await,
20947            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
20948        ));
20949        assert!(matches!(
20950            stream.next().await,
20951            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
20952        ));
20953        assert!(stream.next().await.is_none());
20954    }
20955
20956    #[test]
20957    fn reconnect_delay_doubles_until_capped() {
20958        assert_eq!(
20959            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
20960            Duration::from_millis(500)
20961        );
20962        assert_eq!(
20963            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
20964            Duration::from_secs(1)
20965        );
20966        assert_eq!(
20967            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
20968            Duration::ZERO
20969        );
20970    }
20971
20972    #[test]
20973    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
20974        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
20975        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
20976
20977        assert!(should_dedupe_record(&included));
20978        assert!(!should_dedupe_record(&removed));
20979    }
20980
20981    #[test]
20982    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
20983        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
20984                                     timestamp: Option<u64>| {
20985            record.context.block.as_mut().expect("block").timestamp = timestamp;
20986            match &mut record.context.chain_status {
20987                ChainStatus::Included { block, .. }
20988                | ChainStatus::Safe { block }
20989                | ChainStatus::Finalized { block }
20990                | ChainStatus::Reorged {
20991                    dropped_from: block,
20992                } => block.timestamp = timestamp,
20993                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
20994                    panic!("log record is canonical")
20995                }
20996            }
20997        };
20998
20999        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
21000        let payload_timestamp = match &payload_only.input {
21001            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
21002            _ => unreachable!(),
21003        };
21004        set_context_timestamp(&mut payload_only, None);
21005        let mut context_only = payload_only.clone();
21006        if let ReactiveInput::Log(log) = &mut context_only.input {
21007            log.block_timestamp = None;
21008        }
21009        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
21010        assert!(matches!(
21011            dedupe_records(vec![payload_only, context_only]),
21012            Err(ReactiveError::InvalidInputRecord { .. })
21013        ));
21014
21015        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
21016        if let ReactiveInput::Log(log) = &mut partial.input {
21017            log.block_timestamp = None;
21018        }
21019        set_context_timestamp(&mut partial, None);
21020        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
21021        let deduped =
21022            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
21023        assert_eq!(deduped.len(), 1);
21024        deduped[0]
21025            .validated_identity()
21026            .expect("merged record remains coherent");
21027        let resolved = resolve_record_block_payload_metadata(
21028            &deduped[0],
21029            *canonical_record_block(&deduped[0]).expect("canonical"),
21030        )
21031        .expect("effective block");
21032        assert_eq!(resolved.timestamp, Some(payload_timestamp));
21033    }
21034
21035    #[test]
21036    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
21037        use alloy_rpc_types_eth::{Block, Header};
21038
21039        let block_ref = BlockRef {
21040            number: 7,
21041            hash: B256::repeat_byte(0x77),
21042            parent_hash: Some(B256::repeat_byte(0x66)),
21043            timestamp: Some(1_700_000_007),
21044        };
21045        let block = Block::empty(Header {
21046            hash: block_ref.hash,
21047            inner: alloy_consensus::Header {
21048                number: block_ref.number,
21049                parent_hash: block_ref.parent_hash.expect("parent"),
21050                timestamp: block_ref.timestamp.expect("timestamp"),
21051                ..Default::default()
21052            },
21053            total_difficulty: None,
21054            size: None,
21055        });
21056        let record = ReactiveInputRecord::<Ethereum>::new(
21057            ReactiveInput::FullBlock(block),
21058            ReactiveContext {
21059                chain_id: Some(1),
21060                source: InputSource::Subscription,
21061                chain_status: ChainStatus::Included {
21062                    block: block_ref,
21063                    confirmations: 0,
21064                },
21065                block: Some(block_ref),
21066                transaction_index: None,
21067                log_index: None,
21068            },
21069        );
21070
21071        assert!(!record.is_payload_deduplicable());
21072        assert!(!record.same_deduplicable_payload(&record));
21073        let retained = dedupe_scoped_records(vec![
21074            (
21075                record.clone(),
21076                DeliveryAudience::All,
21077                DeliveryScope::Canonical,
21078            ),
21079            (record, DeliveryAudience::All, DeliveryScope::Canonical),
21080        ])
21081        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
21082        assert_eq!(retained.len(), 2);
21083    }
21084
21085    #[test]
21086    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
21087        let pending_context = ReactiveContext {
21088            chain_id: Some(1),
21089            source: InputSource::Batch,
21090            chain_status: ChainStatus::Pending,
21091            block: None,
21092            transaction_index: None,
21093            log_index: None,
21094        };
21095        let mut included_pending = rpc_transaction(Some(1));
21096        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
21097        assert!(matches!(
21098            ReactiveInputRecord::<Ethereum>::new(
21099                ReactiveInput::PendingTx(included_pending),
21100                pending_context.clone(),
21101            )
21102            .validated_identity(),
21103            Err(ReactiveError::InvalidInputRecord { .. })
21104        ));
21105        assert!(matches!(
21106            ReactiveInputRecord::<Ethereum>::new(
21107                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
21108                pending_context,
21109            )
21110            .validated_identity(),
21111            Err(ReactiveError::InvalidInputRecord { .. })
21112        ));
21113
21114        let block_ref = BlockRef {
21115            number: 8,
21116            hash: B256::repeat_byte(0x88),
21117            parent_hash: Some(B256::repeat_byte(0x77)),
21118            timestamp: Some(1_700_000_008),
21119        };
21120        let header = alloy_rpc_types_eth::Header {
21121            hash: block_ref.hash,
21122            inner: alloy_consensus::Header {
21123                number: block_ref.number,
21124                parent_hash: block_ref.parent_hash.expect("parent"),
21125                timestamp: block_ref.timestamp.expect("timestamp"),
21126                ..Default::default()
21127            },
21128            total_difficulty: None,
21129            size: None,
21130        };
21131        let context = ReactiveContext {
21132            chain_id: Some(1),
21133            source: InputSource::Batch,
21134            chain_status: ChainStatus::Included {
21135                block: block_ref,
21136                confirmations: 0,
21137            },
21138            block: Some(block_ref),
21139            transaction_index: None,
21140            log_index: None,
21141        };
21142        for transaction in [
21143            alloy_rpc_types_eth::Transaction {
21144                block_hash: Some(B256::repeat_byte(0xff)),
21145                ..rpc_transaction(Some(1))
21146            },
21147            alloy_rpc_types_eth::Transaction {
21148                block_hash: Some(block_ref.hash),
21149                block_number: Some(block_ref.number),
21150                transaction_index: Some(1),
21151                ..rpc_transaction(Some(1))
21152            },
21153            rpc_transaction(Some(2)),
21154        ] {
21155            let block = alloy_rpc_types_eth::Block::new(
21156                header.clone(),
21157                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
21158            );
21159            assert!(matches!(
21160                ReactiveInputRecord::<Ethereum>::new(
21161                    ReactiveInput::FullBlock(block),
21162                    context.clone(),
21163                )
21164                .validated_identity(),
21165                Err(ReactiveError::InvalidInputRecord { .. })
21166            ));
21167        }
21168    }
21169
21170    #[test]
21171    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21172    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
21173        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21174        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21175            provider,
21176            SubscriberMode::Auto,
21177            SubscriberConfig::default(),
21178        );
21179        let owner = HandlerId::new("compat-owner");
21180        subscriber
21181            .add_interest_owner(
21182                owner.clone(),
21183                &[ReactiveInterest::Logs(LogInterest {
21184                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21185                    local_matcher: None,
21186                    route_key: None,
21187                })],
21188            )
21189            .unwrap();
21190        let log = rpc_log(false);
21191
21192        subscriber.enqueue_compat_owner_record(
21193            log_input_record(log.clone(), InputSource::Backfill),
21194            owner.clone(),
21195        );
21196        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
21197
21198        let batch = subscriber
21199            .drain_next_scoped_batch()
21200            .expect("owner catch-up and residual live copies");
21201        assert_eq!(batch.records.len(), 2);
21202        assert_eq!(
21203            batch.records[0].scope,
21204            SubscriberInputScope::OwnerOnlyHandlers {
21205                owners: vec![owner.clone()]
21206            }
21207        );
21208        assert_eq!(
21209            batch.records[1].scope,
21210            SubscriberInputScope::CanonicalResidual {
21211                owners: Vec::new(),
21212                excluded: vec![owner.clone()]
21213            }
21214        );
21215
21216        let reactive = batch.into_reactive_batch();
21217        assert_eq!(
21218            reactive.record_audience(0),
21219            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
21220        );
21221        assert_eq!(
21222            reactive.record_delivery_scope(0),
21223            Some(DeliveryScope::OwnerCatchup)
21224        );
21225        assert_eq!(
21226            reactive.record_audience(1),
21227            Some(&DeliveryAudience::AllExcept(vec![owner]))
21228        );
21229        assert_eq!(
21230            reactive.record_delivery_scope(1),
21231            Some(DeliveryScope::Canonical)
21232        );
21233    }
21234
21235    #[test]
21236    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21237    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
21238        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21239        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21240            provider,
21241            SubscriberMode::Auto,
21242            SubscriberConfig::default(),
21243        );
21244        let owner = HandlerId::new("replace-owner");
21245        let original = ReactiveInterest::Logs(LogInterest {
21246            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
21247            local_matcher: None,
21248            route_key: None,
21249        });
21250        let replacement_interest = ReactiveInterest::Logs(LogInterest {
21251            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21252            local_matcher: None,
21253            route_key: None,
21254        });
21255        let active = subscriber
21256            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
21257            .unwrap();
21258        assert!(subscriber.activate_interest_owner(&active));
21259        let replacement = subscriber
21260            .stage_interest_owner_replacement(
21261                owner,
21262                &[replacement_interest],
21263                SubscriberOwnerStart::Live,
21264            )
21265            .unwrap();
21266
21267        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
21268        assert_eq!(subscriber.interest_owner_state(&active), None);
21269        assert_eq!(
21270            subscriber.interest_owner_state(&replacement),
21271            Some(SubscriberOwnerState::Active)
21272        );
21273        assert_eq!(subscriber.registered_interests().len(), 1);
21274    }
21275
21276    #[test]
21277    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21278    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
21279        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21280        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21281            provider,
21282            SubscriberMode::Auto,
21283            SubscriberConfig::default(),
21284        );
21285        let owner = HandlerId::new("one-lifecycle");
21286        let interest = ReactiveInterest::Logs(LogInterest {
21287            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21288            local_matcher: None,
21289            route_key: None,
21290        });
21291        let epoch = subscriber
21292            .stage_interest_owner(
21293                owner.clone(),
21294                std::slice::from_ref(&interest),
21295                SubscriberOwnerStart::Live,
21296            )
21297            .expect("stage epoch owner");
21298
21299        assert!(matches!(
21300            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
21301            Err(SubscriberError::InvalidConfig(_))
21302        ));
21303        assert_eq!(
21304            subscriber.interest_owner_state(&epoch),
21305            Some(SubscriberOwnerState::Staged)
21306        );
21307        assert!(subscriber.abort_interest_owner(&epoch));
21308        subscriber
21309            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
21310            .expect("compatibility owner after epoch abort");
21311        assert!(matches!(
21312            subscriber.stage_interest_owner_replacement(
21313                owner,
21314                std::slice::from_ref(&interest),
21315                SubscriberOwnerStart::Live,
21316            ),
21317            Err(SubscriberOwnerError::AlreadyRegistered(_))
21318        ));
21319    }
21320
21321    #[test]
21322    fn pending_record_overflow_is_sticky_and_fail_closed() {
21323        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21324        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21325            provider,
21326            SubscriberMode::Polling,
21327            SubscriberConfig {
21328                max_pending_records: 1,
21329                ..SubscriberConfig::default()
21330            },
21331        );
21332        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
21333            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21334            local_matcher: None,
21335            route_key: None,
21336        })];
21337        subscriber.enqueue_event(SubscriberEvent::Log {
21338            source_id: 0,
21339            log: rpc_log(false),
21340        });
21341        let mut second = rpc_log(false);
21342        second.log_index = Some(6);
21343        second.transaction_hash = Some(B256::repeat_byte(0x04));
21344        subscriber.enqueue_event(SubscriberEvent::Log {
21345            source_id: 0,
21346            log: second,
21347        });
21348
21349        assert_eq!(subscriber.pending_records.len(), 1);
21350        assert!(matches!(
21351            subscriber.check_resource_error(),
21352            Err(SubscriberError::ResourceExhausted(_))
21353        ));
21354        subscriber.reset_delivery_state();
21355        assert!(subscriber.check_resource_error().is_ok());
21356    }
21357
21358    #[test]
21359    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
21360        let baseline = rpc_log(false);
21361        let fixed_bytes =
21362            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
21363                .expect("measure fixed log accounting");
21364        let mut large = baseline;
21365        large.inner = alloy_primitives::Log::new_unchecked(
21366            Address::repeat_byte(0x42),
21367            vec![B256::repeat_byte(0x01)],
21368            Bytes::from(vec![0u8; 256]),
21369        );
21370
21371        assert!(matches!(
21372            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
21373            Err(SubscriberError::ResourceExhausted(_))
21374        ));
21375    }
21376
21377    #[tokio::test(flavor = "multi_thread")]
21378    #[cfg(feature = "reactive-polling")]
21379    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
21380        use alloy_rpc_types_eth::{Block, Header};
21381
21382        let asserter = Asserter::new();
21383        let baseline = BlockRef {
21384            number: 6,
21385            hash: B256::repeat_byte(6),
21386            parent_hash: Some(B256::repeat_byte(5)),
21387            timestamp: Some(1_700_000_006),
21388        };
21389        let through = BlockRef {
21390            number: 7,
21391            hash: B256::repeat_byte(7),
21392            parent_hash: Some(baseline.hash),
21393            timestamp: Some(1_700_000_007),
21394        };
21395        let rpc_block = || -> Block {
21396            Block::empty(Header {
21397                hash: through.hash,
21398                inner: alloy_consensus::Header {
21399                    number: through.number,
21400                    parent_hash: through.parent_hash.expect("parent"),
21401                    timestamp: through.timestamp.expect("timestamp"),
21402                    ..Default::default()
21403                },
21404                total_difficulty: None,
21405                size: None,
21406            })
21407        };
21408        let mut historical = rpc_log(false);
21409        historical.block_hash = Some(through.hash);
21410        historical.block_timestamp = through.timestamp;
21411        asserter.push_success(&Some(rpc_block()));
21412        asserter.push_success(&vec![historical]);
21413        asserter.push_success(&Some(rpc_block()));
21414        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21415        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21416            provider,
21417            SubscriberMode::Polling,
21418            SubscriberConfig {
21419                max_pending_records: 1,
21420                ..SubscriberConfig::default()
21421            },
21422        );
21423        subscriber.chain_id = Some(1);
21424        let interest = ReactiveInterest::Logs(LogInterest {
21425            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21426            local_matcher: None,
21427            route_key: None,
21428        });
21429        let epoch = subscriber
21430            .stage_interest_owner(
21431                HandlerId::new("capacity-owner"),
21432                std::slice::from_ref(&interest),
21433                SubscriberOwnerStart::PostBlock(baseline),
21434            )
21435            .expect("stage owner");
21436        // Isolate the commit-side capacity edge: the live queue acquired one
21437        // canonical record while the historical request was in flight.
21438        subscriber.sources_dirty = false;
21439        subscriber.state = AlloySubscriberState::Empty;
21440        subscriber.push_pending_record(SubscriberInputRecord {
21441            record: log_input_record(rpc_log(false), InputSource::Poll),
21442            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
21443            preconfirmation_timing: None,
21444        });
21445
21446        let error = subscriber
21447            .reconcile_interest_owner(&epoch, through)
21448            .await
21449            .expect_err("historical delivery cannot displace the queued live record");
21450        assert!(matches!(
21451            error,
21452            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
21453        ));
21454        assert!(subscriber.interest_owner_progress(&epoch).is_none());
21455        assert_eq!(subscriber.pending_records.len(), 1);
21456        assert!(matches!(
21457            subscriber.pending_records[0].scope,
21458            SubscriberInputScope::Canonical { .. }
21459        ));
21460    }
21461
21462    #[test]
21463    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21464    fn lazy_backfill_queue_capacity_failure_is_atomic() {
21465        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21466        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21467            provider,
21468            SubscriberMode::Auto,
21469            SubscriberConfig {
21470                max_pending_backfills: 1,
21471                ..SubscriberConfig::default()
21472            },
21473        );
21474        let interest = |address| {
21475            ReactiveInterest::Logs(LogInterest {
21476                provider_filter: Filter::new().address(address),
21477                local_matcher: None,
21478                route_key: None,
21479            })
21480        };
21481        subscriber
21482            .add_interest_owner_with_backfill(
21483                HandlerId::new("owner-a"),
21484                &[interest(Address::repeat_byte(0x41))],
21485                SubscriberBackfill::from_block(10),
21486            )
21487            .expect("first queued backfill");
21488
21489        let error = subscriber
21490            .add_interest_owner_with_backfill(
21491                HandlerId::new("owner-b"),
21492                &[interest(Address::repeat_byte(0x42))],
21493                SubscriberBackfill::from_block(10),
21494            )
21495            .expect_err("second backfill must exceed capacity");
21496
21497        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
21498        assert!(
21499            subscriber
21500                .owner_interests(&HandlerId::new("owner-b"))
21501                .is_none()
21502        );
21503        assert_eq!(subscriber.pending_backfills.len(), 1);
21504    }
21505
21506    #[test]
21507    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21508    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
21509        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21510        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21511            provider,
21512            SubscriberMode::Auto,
21513            SubscriberConfig {
21514                max_pending_backfills: 1,
21515                ..SubscriberConfig::default()
21516            },
21517        );
21518        let interest = |address| {
21519            ReactiveInterest::Logs(LogInterest {
21520                provider_filter: Filter::new().address(address),
21521                local_matcher: None,
21522                route_key: None,
21523            })
21524        };
21525        subscriber
21526            .add_interest_owner(
21527                HandlerId::new("crash-stale"),
21528                &[interest(Address::repeat_byte(0xee))],
21529            )
21530            .expect("seed stale owner");
21531        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
21532        subscriber.rebuild_registered_interests();
21533        subscriber.push_pending_record(SubscriberInputRecord {
21534            record: log_input_record(rpc_log(false), InputSource::Poll),
21535            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
21536            preconfirmation_timing: None,
21537        });
21538        let baseline = BlockRef {
21539            number: 100,
21540            hash: B256::repeat_byte(100),
21541            parent_hash: Some(B256::repeat_byte(99)),
21542            timestamp: Some(1_700_000_100),
21543        };
21544        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
21545
21546        let error = subscriber
21547            .replace_interest_owners_with_global_backfill(
21548                vec![
21549                    (
21550                        HandlerId::new("pool-a"),
21551                        vec![interest(Address::repeat_byte(0xa1))],
21552                    ),
21553                    (
21554                        HandlerId::new("pool-b"),
21555                        vec![ReactiveInterest::Logs(LogInterest {
21556                            // A distinct block option prevents provider-filter
21557                            // fan-in, exercising the two-unit capacity edge.
21558                            provider_filter: Filter::new()
21559                                .address(Address::repeat_byte(0xb2))
21560                                .from_block(7),
21561                            local_matcher: None,
21562                            route_key: None,
21563                        })],
21564                    ),
21565                ],
21566                backfill,
21567            )
21568            .expect_err("two backfills exceed atomic capacity");
21569        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
21570        assert!(
21571            subscriber
21572                .owner_interests(&HandlerId::new("crash-stale"))
21573                .is_some(),
21574            "failed replacement must preserve the prior topology"
21575        );
21576        assert!(
21577            subscriber
21578                .owner_interests(&HandlerId::new("pool-a"))
21579                .is_none()
21580        );
21581        assert_eq!(subscriber.base_interests.len(), 1);
21582        assert_eq!(subscriber.pending_records.len(), 1);
21583
21584        subscriber
21585            .replace_interest_owners_with_global_backfill(
21586                vec![(
21587                    HandlerId::new("pool-a"),
21588                    vec![interest(Address::repeat_byte(0xa1))],
21589                )],
21590                backfill,
21591            )
21592            .expect("replacement within capacity");
21593        assert!(
21594            subscriber
21595                .owner_interests(&HandlerId::new("crash-stale"))
21596                .is_none(),
21597            "successful exact replacement removes stale owners"
21598        );
21599        assert!(
21600            subscriber.base_interests.is_empty(),
21601            "successful exact replacement removes stale unowned interests"
21602        );
21603        assert!(
21604            subscriber.drain_next_scoped_batch().is_none(),
21605            "stale canonical delivery must not escape before C + 1 recovery"
21606        );
21607        assert!(
21608            subscriber
21609                .owner_interests(&HandlerId::new("pool-a"))
21610                .is_some()
21611        );
21612        assert_eq!(subscriber.pending_backfills.len(), 1);
21613        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
21614        assert!(
21615            subscriber.pending_backfills[0].owner.is_none(),
21616            "startup history must be global canonical catch-up, not owner-only"
21617        );
21618    }
21619
21620    #[test]
21621    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
21622        let baseline = BlockRef {
21623            number: u64::MAX,
21624            hash: B256::repeat_byte(0xff),
21625            parent_hash: None,
21626            timestamp: None,
21627        };
21628        assert!(matches!(
21629            SubscriberBackfill::after_canonical_block(baseline),
21630            Err(SubscriberError::InvalidConfig(_))
21631        ));
21632    }
21633
21634    #[tokio::test(flavor = "multi_thread")]
21635    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
21636    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
21637        let asserter = Asserter::new();
21638        asserter.push_success(&101u64);
21639        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
21640        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
21641        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21642        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21643            provider,
21644            SubscriberMode::Auto,
21645            SubscriberConfig::default(),
21646        );
21647        let baseline = BlockRef {
21648            number: 100,
21649            hash: B256::repeat_byte(0xaa),
21650            parent_hash: None,
21651            timestamp: None,
21652        };
21653        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
21654        subscriber
21655            .add_interest_owner_with_backfill(
21656                HandlerId::new("pool"),
21657                &[ReactiveInterest::Logs(LogInterest {
21658                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
21659                    local_matcher: None,
21660                    route_key: None,
21661                })],
21662                backfill,
21663            )
21664            .expect("queue post-baseline backfill");
21665
21666        let error = subscriber
21667            .drain_pending_backfills()
21668            .await
21669            .expect_err("provider branch differs at retained baseline");
21670        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
21671        assert_eq!(subscriber.pending_backfills.len(), 1);
21672        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
21673        assert!(subscriber.pending_records.is_empty());
21674    }
21675
21676    #[tokio::test(flavor = "multi_thread")]
21677    #[cfg(feature = "reactive-ws")]
21678    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
21679    {
21680        let asserter = Asserter::new();
21681        let retained = BlockRef {
21682            number: 10,
21683            hash: B256::repeat_byte(10),
21684            parent_hash: Some(B256::repeat_byte(9)),
21685            timestamp: Some(1_700_000_010),
21686        };
21687        let activation = BlockRef {
21688            number: 12,
21689            hash: B256::repeat_byte(12),
21690            parent_hash: Some(B256::repeat_byte(11)),
21691            timestamp: Some(1_700_000_012),
21692        };
21693
21694        // 257 distinct logical block options cross the 256-filter request
21695        // chunk boundary. Each window therefore makes two concurrent log
21696        // requests whose responses deliberately arrive in reverse order.
21697        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21698        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
21699        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
21700        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21701        asserter.push_success(&activation.number);
21702        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
21703        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21704        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
21705        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
21706        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
21707        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21708        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21709            provider,
21710            SubscriberMode::Auto,
21711            SubscriberConfig::default(),
21712        );
21713        let interests = (0..257)
21714            .map(|start| {
21715                ReactiveInterest::Logs(LogInterest {
21716                    provider_filter: Filter::new()
21717                        .address(Address::repeat_byte(0x42))
21718                        .event_signature(B256::repeat_byte(0x01))
21719                        .from_block(start),
21720                    local_matcher: None,
21721                    route_key: None,
21722                })
21723            })
21724            .collect::<Vec<_>>();
21725        subscriber
21726            .add_interest_owner_with_canonical_catchup(
21727                HandlerId::new("many-filters"),
21728                &interests,
21729                retained,
21730            )
21731            .expect("queue coordinated windows");
21732        assert_eq!(subscriber.pending_backfills.len(), 2);
21733        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
21734        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
21735
21736        subscriber
21737            .drain_pending_backfills()
21738            .await
21739            .expect("owner filter group");
21740        let owner = subscriber
21741            .drain_next_scoped_batch()
21742            .expect("owner ordered batch");
21743        assert_eq!(owner.records.len(), 2);
21744        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
21745        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
21746        assert!(
21747            owner.records.iter().all(|record| matches!(
21748                record.scope,
21749                SubscriberInputScope::OwnerOnlyHandlers { .. }
21750            ))
21751        );
21752
21753        subscriber
21754            .drain_pending_backfills()
21755            .await
21756            .expect("global filter group");
21757        let global = subscriber
21758            .drain_next_scoped_batch()
21759            .expect("global ordered batch");
21760        assert_eq!(global.records.len(), 2);
21761        assert_eq!(
21762            global.records[0].record.context.block.map(|b| b.number),
21763            Some(11)
21764        );
21765        assert_eq!(
21766            global.records[1].record.context.block.map(|b| b.number),
21767            Some(12)
21768        );
21769        assert!(
21770            global
21771                .records
21772                .iter()
21773                .all(|record| record.scope.is_canonical())
21774        );
21775        assert!(matches!(
21776            global.chain_controls.as_slice(),
21777            [ChainControl::Barrier {
21778                block: Some(block),
21779                ..
21780            }] if block == &activation
21781        ));
21782    }
21783
21784    #[tokio::test(flavor = "multi_thread")]
21785    #[cfg(feature = "reactive-ws")]
21786    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
21787        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21788        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21789            provider,
21790            SubscriberMode::PubSub,
21791            SubscriberConfig::default(),
21792        );
21793        subscriber.chain_id = Some(1);
21794        let interest = ReactiveInterest::Logs(LogInterest {
21795            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21796            local_matcher: None,
21797            route_key: None,
21798        });
21799        let owner_a = subscriber
21800            .stage_interest_owner(
21801                HandlerId::new("owner-a"),
21802                std::slice::from_ref(&interest),
21803                SubscriberOwnerStart::Live,
21804            )
21805            .unwrap();
21806        let owner_b = subscriber
21807            .stage_interest_owner(
21808                HandlerId::new("owner-b"),
21809                &[interest],
21810                SubscriberOwnerStart::Live,
21811            )
21812            .unwrap();
21813
21814        subscriber.enqueue_event(SubscriberEvent::Log {
21815            source_id: 0,
21816            log: rpc_log(false),
21817        });
21818        assert!(subscriber.abort_interest_owner(&owner_a));
21819
21820        let batch = subscriber
21821            .next_scoped_batch()
21822            .await
21823            .unwrap()
21824            .expect("shared canonical delivery remains queued");
21825        assert_eq!(batch.records.len(), 1);
21826        assert_eq!(
21827            batch.records[0].scope,
21828            SubscriberInputScope::Canonical {
21829                owners: vec![owner_b]
21830            }
21831        );
21832    }
21833
21834    #[tokio::test(flavor = "multi_thread")]
21835    #[cfg(feature = "reactive-ws")]
21836    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
21837        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21838        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21839            provider,
21840            SubscriberMode::PubSub,
21841            SubscriberConfig::default(),
21842        );
21843        subscriber.chain_id = Some(1);
21844        let epoch = subscriber
21845            .stage_interest_owner(
21846                HandlerId::new("owner"),
21847                &[ReactiveInterest::Logs(LogInterest {
21848                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21849                    local_matcher: None,
21850                    route_key: None,
21851                })],
21852                SubscriberOwnerStart::Live,
21853            )
21854            .unwrap();
21855        let log = rpc_log(false);
21856
21857        subscriber.enqueue_owner_record(
21858            log_input_record(log.clone(), InputSource::Backfill),
21859            epoch.clone(),
21860        );
21861        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
21862
21863        let batch = subscriber
21864            .next_scoped_batch()
21865            .await
21866            .unwrap()
21867            .expect("owner backfill and canonical live delivery");
21868        assert_eq!(batch.records.len(), 2);
21869        assert_eq!(
21870            batch.records[0].scope,
21871            SubscriberInputScope::OwnerOnly {
21872                owners: vec![epoch]
21873            }
21874        );
21875        assert_eq!(
21876            batch.records[1].scope,
21877            SubscriberInputScope::Canonical { owners: Vec::new() },
21878            "owner replay dedupe must not suppress the global live record"
21879        );
21880    }
21881
21882    #[tokio::test(flavor = "multi_thread")]
21883    #[cfg(feature = "reactive-polling")]
21884    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
21885        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21886        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21887            provider,
21888            SubscriberMode::Polling,
21889            SubscriberConfig {
21890                max_batch_size: 2,
21891                ..SubscriberConfig::default()
21892            },
21893        );
21894        let interest = ReactiveInterest::Logs(LogInterest {
21895            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21896            local_matcher: None,
21897            route_key: None,
21898        });
21899        let epoch = subscriber
21900            .stage_interest_owner(
21901                HandlerId::new("owner"),
21902                std::slice::from_ref(&interest),
21903                SubscriberOwnerStart::Live,
21904            )
21905            .unwrap();
21906        subscriber.sources_dirty = false;
21907
21908        let mut duplicate = rpc_log(false);
21909        duplicate.transaction_hash = Some(B256::repeat_byte(1));
21910        duplicate.log_index = Some(0);
21911        let events = (0u8..10).map(|index| {
21912            let mut log = rpc_log(false);
21913            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
21914            log.log_index = Some(index as u64);
21915            SubscriberEvent::Log { source_id: 0, log }
21916        });
21917        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
21918        let mut streams = SubscriberStreams::new();
21919        streams.push(
21920            SubscriberStreamSource::PollingLog { filter },
21921            stream::iter(events).boxed(),
21922        );
21923        subscriber.state = AlloySubscriberState::Active(streams);
21924
21925        let mut polls = 0usize;
21926        let fetched_duplicate = duplicate.clone();
21927        let fetch = poll_fn(move |cx| {
21928            polls += 1;
21929            if polls > 10 {
21930                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
21931            } else {
21932                cx.waker().wake_by_ref();
21933                std::task::Poll::Pending
21934            }
21935        });
21936        let target_epochs = HashSet::from([epoch.clone()]);
21937        let fetched_duplicate = subscriber
21938            .drive_reconcile_fetch(fetch, &target_epochs)
21939            .await
21940            .unwrap();
21941        subscriber.enqueue_owner_record_for_owners_unmerged(
21942            log_input_record(fetched_duplicate, InputSource::Backfill),
21943            vec![epoch.clone()],
21944        );
21945        subscriber.promote_reconcile_owner_records(&target_epochs);
21946
21947        assert_eq!(subscriber.pending_records.len(), 20);
21948        assert!(subscriber.pending_records.iter().take(10).all(|record| {
21949            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
21950        }));
21951        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
21952            record.scope
21953                == SubscriberInputScope::OwnerOnly {
21954                    owners: vec![epoch.clone()],
21955                }
21956        }));
21957    }
21958
21959    #[tokio::test(flavor = "multi_thread")]
21960    #[cfg(feature = "reactive-polling")]
21961    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
21962        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21963        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21964            provider,
21965            SubscriberMode::Polling,
21966            SubscriberConfig::default(),
21967        );
21968        subscriber.chain_id = Some(1);
21969        subscriber.sources_dirty = false;
21970        let mut first_poll = true;
21971        let fetch = poll_fn(move |cx| {
21972            if first_poll {
21973                first_poll = false;
21974                cx.waker().wake_by_ref();
21975                std::task::Poll::Pending
21976            } else {
21977                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
21978            }
21979        });
21980
21981        let result = subscriber
21982            .drive_reconcile_fetch(fetch, &HashSet::new())
21983            .await
21984            .expect("an empty live topology must not be mistaken for termination");
21985        assert_eq!(result, "certified");
21986    }
21987
21988    #[tokio::test(flavor = "multi_thread")]
21989    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
21990    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
21991        use alloy_rpc_types_eth::{Block, Header};
21992
21993        let asserter = Asserter::new();
21994        let baseline = BlockRef {
21995            number: 100,
21996            hash: B256::repeat_byte(0x64),
21997            parent_hash: Some(B256::repeat_byte(0x63)),
21998            timestamp: Some(1_700_000_100),
21999        };
22000        let through = BlockRef {
22001            number: 101,
22002            hash: B256::repeat_byte(0x65),
22003            parent_hash: Some(baseline.hash),
22004            timestamp: Some(1_700_000_101),
22005        };
22006        let rpc_block = || -> Block {
22007            Block::empty(Header {
22008                hash: through.hash,
22009                inner: alloy_consensus::Header {
22010                    number: through.number,
22011                    parent_hash: through.parent_hash.unwrap(),
22012                    timestamp: through.timestamp.unwrap(),
22013                    ..Default::default()
22014                },
22015                total_difficulty: None,
22016                size: None,
22017            })
22018        };
22019        asserter.push_success(&Some(rpc_block()));
22020        asserter.push_success(&Vec::<Log>::new());
22021        asserter.push_success(&Some(rpc_block()));
22022        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
22023        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22024            provider,
22025            SubscriberMode::PubSub,
22026            SubscriberConfig::default(),
22027        );
22028        subscriber.chain_id = Some(1);
22029        let interest = ReactiveInterest::Logs(LogInterest {
22030            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
22031            local_matcher: None,
22032            route_key: None,
22033        });
22034        let epoch = subscriber
22035            .stage_interest_owner(
22036                HandlerId::new("reconnect-anchor"),
22037                std::slice::from_ref(&interest),
22038                SubscriberOwnerStart::PostBlock(baseline),
22039            )
22040            .unwrap();
22041        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
22042        let source = SubscriberStreamSource::PubSubLog {
22043            id: subscriber.log_source_id(&filter),
22044            filter: filter.clone(),
22045        };
22046        let mut streams = SubscriberStreams::new();
22047        streams.push(source, stream::pending().boxed());
22048        subscriber.state = AlloySubscriberState::Active(streams);
22049        subscriber.sources_dirty = false;
22050
22051        subscriber
22052            .reconcile_interest_owner(&epoch, through)
22053            .await
22054            .unwrap();
22055        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
22056        assert!(asserter.read_q().is_empty());
22057    }
22058
22059    #[tokio::test(flavor = "multi_thread")]
22060    #[cfg(feature = "reactive-polling")]
22061    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
22062        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22063        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22064            provider,
22065            SubscriberMode::Polling,
22066            SubscriberConfig::default(),
22067        );
22068        let interest = ReactiveInterest::Logs(LogInterest {
22069            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22070            local_matcher: None,
22071            route_key: None,
22072        });
22073        let epoch = subscriber
22074            .stage_interest_owner(
22075                HandlerId::new("owner"),
22076                std::slice::from_ref(&interest),
22077                SubscriberOwnerStart::PostBlock(BlockRef {
22078                    number: 100,
22079                    hash: B256::repeat_byte(0x64),
22080                    parent_hash: None,
22081                    timestamp: None,
22082                }),
22083            )
22084            .unwrap();
22085        subscriber.sources_dirty = false;
22086
22087        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
22088        let event = SubscriberEvent::Log {
22089            source_id: 0,
22090            log: rpc_log(false),
22091        };
22092        let mut streams = SubscriberStreams::new();
22093        streams.push(
22094            SubscriberStreamSource::PollingLog { filter },
22095            stream::once(async move { event })
22096                .chain(stream::pending())
22097                .boxed(),
22098        );
22099        subscriber.state = AlloySubscriberState::Active(streams);
22100
22101        let targets = HashSet::from([epoch.clone()]);
22102        {
22103            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
22104            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
22105            futures::pin_mut!(drive);
22106            poll_fn(|cx| {
22107                assert!(drive.as_mut().poll(cx).is_pending());
22108                std::task::Poll::Ready(())
22109            })
22110            .await;
22111        }
22112
22113        assert_eq!(subscriber.pending_records.len(), 1);
22114        assert_eq!(
22115            subscriber.pending_records[0].scope,
22116            SubscriberInputScope::Canonical { owners: Vec::new() },
22117            "canonical delivery commits immediately at a cancellation-safe boundary"
22118        );
22119        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
22120
22121        subscriber
22122            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
22123            .await
22124            .unwrap();
22125        subscriber.promote_reconcile_owner_records(&targets);
22126        assert!(subscriber.pending_reconcile_owner_records.is_empty());
22127        assert_eq!(subscriber.pending_records.len(), 2);
22128        assert_eq!(
22129            subscriber.pending_records[0].scope,
22130            SubscriberInputScope::Canonical { owners: Vec::new() },
22131            "canonical delivery remains target-excluded"
22132        );
22133        assert_eq!(
22134            subscriber.pending_records[1].scope,
22135            SubscriberInputScope::OwnerOnly {
22136                owners: vec![epoch]
22137            },
22138            "retry commit appends hidden owner delivery after historical catch-up"
22139        );
22140    }
22141
22142    #[tokio::test(flavor = "multi_thread")]
22143    #[cfg(feature = "reactive-ws")]
22144    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
22145        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22146        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22147            provider,
22148            SubscriberMode::PubSub,
22149            SubscriberConfig {
22150                reconnect: SubscriberReconnectConfig {
22151                    initial_delay: Duration::from_secs(60),
22152                    ..SubscriberReconnectConfig::default()
22153                },
22154                ..SubscriberConfig::default()
22155            },
22156        );
22157        subscriber.chain_id = Some(1);
22158        let interest = ReactiveInterest::Logs(LogInterest {
22159            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
22160            local_matcher: None,
22161            route_key: None,
22162        });
22163        let epoch = subscriber
22164            .stage_interest_owner(
22165                HandlerId::new("owner"),
22166                std::slice::from_ref(&interest),
22167                SubscriberOwnerStart::PostBlock(BlockRef {
22168                    number: 7,
22169                    hash: B256::repeat_byte(0x07),
22170                    parent_hash: Some(B256::repeat_byte(0x06)),
22171                    timestamp: Some(1_700_000_007),
22172                }),
22173            )
22174            .unwrap();
22175        subscriber.sources_dirty = false;
22176        subscriber.stream_revision = 1;
22177        let entry = subscriber
22178            .owned_interests
22179            .iter_mut()
22180            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
22181            .unwrap();
22182        entry.progress = Some(SubscriberOwnerProgress {
22183            owner: epoch.clone(),
22184            through: entry.baseline.unwrap(),
22185        });
22186        entry.progress_stream_revision = Some(1);
22187
22188        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
22189        let source = SubscriberStreamSource::PubSubLog {
22190            id: subscriber.log_source_id(&filter),
22191            filter,
22192        };
22193        let mut streams = SubscriberStreams::new();
22194        streams.push(
22195            source.clone(),
22196            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
22197        );
22198        subscriber.state = AlloySubscriberState::Active(streams);
22199        let prior_revision = subscriber.stream_revision;
22200
22201        let mut first_poll = true;
22202        let control = poll_fn(move |cx| {
22203            if first_poll {
22204                first_poll = false;
22205                cx.waker().wake_by_ref();
22206                std::task::Poll::Pending
22207            } else {
22208                std::task::Poll::Ready("stop")
22209            }
22210        });
22211        futures::pin_mut!(control);
22212        let outcome = subscriber
22213            .next_scoped_batch_or(control.as_mut())
22214            .await
22215            .unwrap();
22216
22217        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
22218        assert!(subscriber.sources_dirty);
22219        assert!(subscriber.stream_revision > prior_revision);
22220        assert!(
22221            !subscriber.activate_interest_owner(&epoch),
22222            "progress certified against the terminated stream revision is stale"
22223        );
22224    }
22225
22226    #[tokio::test]
22227    #[cfg(feature = "reactive-ws")]
22228    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
22229        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22230        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22231            provider,
22232            SubscriberMode::PubSub,
22233            SubscriberConfig::default(),
22234        );
22235        subscriber.chain_id = Some(1);
22236        subscriber
22237            .register_interests(&[
22238                ReactiveInterest::Logs(LogInterest {
22239                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
22240                    local_matcher: None,
22241                    route_key: None,
22242                }),
22243                ReactiveInterest::Logs(LogInterest {
22244                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
22245                    local_matcher: None,
22246                    route_key: None,
22247                }),
22248                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
22249            ])
22250            .await
22251            .expect("register base interests");
22252
22253        // The two default-block-option log filters merge into one address
22254        // superset (existing consolidation behavior), so there is one log source
22255        // — assigned id 0, before the pending-hash source.
22256        let sources = subscriber.stream_sources().expect("stream sources");
22257        assert_eq!(sources.len(), 2);
22258        assert!(matches!(
22259            &sources[0],
22260            SubscriberStreamSource::PubSubLog { id: 0, .. }
22261        ));
22262        assert!(matches!(
22263            sources[1],
22264            SubscriberStreamSource::PubSubPendingHashes
22265        ));
22266
22267        // Ids are stable across repeated source construction.
22268        let again = subscriber.stream_sources().expect("stream sources again");
22269        assert!(again[0].same_key(&sources[0]));
22270    }
22271
22272    #[tokio::test(flavor = "multi_thread")]
22273    #[cfg(feature = "reactive-ws")]
22274    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
22275        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22276        let mut subscriber = AlloySubscriber::new(
22277            provider,
22278            SubscriberMode::PubSub,
22279            SubscriberConfig {
22280                reconnect: SubscriberReconnectConfig {
22281                    initial_delay: Duration::ZERO,
22282                    retry_delay: Duration::ZERO,
22283                    max_delay: Duration::ZERO,
22284                    max_attempts: Some(1),
22285                    ..SubscriberReconnectConfig::default()
22286                },
22287                ..SubscriberConfig::default()
22288            },
22289        );
22290        subscriber.chain_id = Some(1);
22291        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
22292            PendingTxInterest::default(),
22293        )];
22294
22295        let mut streams = SubscriberStreams::new();
22296        let source = SubscriberStreamSource::PubSubPendingHashes;
22297        streams.push(
22298            source,
22299            stream::once(async {
22300                SubscriberEvent::<Ethereum>::StreamTerminated(
22301                    SubscriberStreamSource::PubSubPendingHashes,
22302                )
22303            })
22304            .boxed(),
22305        );
22306        subscriber.state = AlloySubscriberState::Active(streams);
22307
22308        let result = subscriber.next_batch().await;
22309        assert!(
22310            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
22311            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
22312        );
22313    }
22314
22315    #[tokio::test]
22316    #[cfg(feature = "reactive-ws")]
22317    async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
22318        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22319        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22320            provider,
22321            SubscriberMode::PubSub,
22322            SubscriberConfig {
22323                preconfirmations: PreconfirmationMode::Required,
22324                ..SubscriberConfig::default()
22325            },
22326        )
22327        .with_provider_ref(ProviderRef::new("base-paid", 7));
22328        subscriber.chain_id = Some(8_453);
22329        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
22330        subscriber.interests = subscriber.base_interests.clone();
22331        subscriber.sources_dirty = false;
22332
22333        let preview: BaseFlashblockWirePayload = serde_json::from_str(
22334            r#"{
22335                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
22336                "number":"0x65",
22337                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
22338                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
22339                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
22340                "timestamp":"0x6553f165",
22341                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
22342            }"#,
22343        )
22344        .unwrap();
22345        let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
22346        subscriber.latest_preconfirmation = Some(preview);
22347
22348        let mut streams = SubscriberStreams::new();
22349        streams.push(
22350            SubscriberStreamSource::BaseFlashblocks,
22351            stream::once(async {
22352                SubscriberEvent::<Ethereum>::StreamTerminated(
22353                    SubscriberStreamSource::BaseFlashblocks,
22354                )
22355            })
22356            .boxed(),
22357        );
22358        subscriber.state = AlloySubscriberState::Active(streams);
22359
22360        let batch = subscriber
22361            .next_scoped_batch()
22362            .await
22363            .expect("termination handling succeeds")
22364            .expect("invalidation is delivered");
22365        assert!(batch.preconfirmation_invalidated());
22366        assert!(subscriber.latest_preconfirmation.is_none());
22367        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
22368        assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
22369        assert!(
22370            subscriber
22371                .pending_flashblock_reconnect_sources
22372                .iter()
22373                .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
22374        );
22375        assert!(
22376            subscriber
22377                .pending_flashblock_reconnect_sources
22378                .iter()
22379                .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
22380        );
22381        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22382            panic!("subscriber remains active while reconnect is pending")
22383        };
22384        assert!(
22385            streams
22386                .entries
22387                .iter()
22388                .all(|entry| !entry.source.is_flashblocks())
22389        );
22390    }
22391
22392    #[tokio::test]
22393    #[cfg(feature = "reactive-ws")]
22394    async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
22395        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22396        let filter = Filter::new().address(Address::repeat_byte(0x42));
22397        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22398            provider,
22399            SubscriberMode::PubSub,
22400            SubscriberConfig {
22401                preconfirmations: PreconfirmationMode::Preferred,
22402                reconnect: SubscriberReconnectConfig {
22403                    enabled: false,
22404                    ..SubscriberReconnectConfig::default()
22405                },
22406                ..SubscriberConfig::default()
22407            },
22408        )
22409        .with_provider_ref(ProviderRef::new("base-paid", 1));
22410        subscriber.chain_id = Some(8_453);
22411        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22412            provider_filter: filter.clone(),
22413            local_matcher: None,
22414            route_key: None,
22415        })];
22416        subscriber.interests = subscriber.base_interests.clone();
22417        subscriber.log_source_ids.insert(filter.clone(), 0);
22418        subscriber.next_log_source_id = 1;
22419
22420        let canonical_source = SubscriberStreamSource::PubSubLog {
22421            id: 0,
22422            filter: filter.clone(),
22423        };
22424        let mut streams = SubscriberStreams::new();
22425        streams.push(
22426            canonical_source.clone(),
22427            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22428        );
22429        subscriber.state = AlloySubscriberState::Active(streams);
22430        subscriber.sources_dirty = true;
22431
22432        subscriber
22433            .ensure_streams()
22434            .await
22435            .expect("preferred Flashblocks setup degrades to canonical-only");
22436        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22437            panic!("canonical stream remains active")
22438        };
22439        assert!(streams.contains_source(&canonical_source));
22440        assert!(
22441            streams
22442                .entries
22443                .iter()
22444                .all(|entry| !entry.source.is_flashblocks())
22445        );
22446        assert!(subscriber.pending_flashblock_reconnects.is_empty());
22447        assert!(!subscriber.sources_dirty);
22448    }
22449
22450    #[tokio::test]
22451    #[cfg(feature = "reactive-ws")]
22452    async fn required_initial_flashblock_rejection_remains_fail_closed() {
22453        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22454        let filter = Filter::new().address(Address::repeat_byte(0x42));
22455        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22456            provider,
22457            SubscriberMode::PubSub,
22458            SubscriberConfig {
22459                preconfirmations: PreconfirmationMode::Required,
22460                reconnect: SubscriberReconnectConfig {
22461                    enabled: false,
22462                    ..SubscriberReconnectConfig::default()
22463                },
22464                ..SubscriberConfig::default()
22465            },
22466        )
22467        .with_provider_ref(ProviderRef::new("base-paid", 1));
22468        subscriber.chain_id = Some(8_453);
22469        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22470            provider_filter: filter.clone(),
22471            local_matcher: None,
22472            route_key: None,
22473        })];
22474        subscriber.interests = subscriber.base_interests.clone();
22475        subscriber.log_source_ids.insert(filter.clone(), 0);
22476        subscriber.next_log_source_id = 1;
22477
22478        let canonical_source = SubscriberStreamSource::PubSubLog {
22479            id: 0,
22480            filter: filter.clone(),
22481        };
22482        let mut streams = SubscriberStreams::new();
22483        streams.push(
22484            canonical_source.clone(),
22485            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22486        );
22487        subscriber.state = AlloySubscriberState::Active(streams);
22488        subscriber.sources_dirty = true;
22489
22490        let error = subscriber
22491            .ensure_streams()
22492            .await
22493            .expect_err("required Flashblocks setup must fail closed");
22494        assert!(matches!(error, SubscriberError::Provider(_)));
22495        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22496            panic!("the already-connected canonical stream is retained")
22497        };
22498        assert!(streams.contains_source(&canonical_source));
22499    }
22500
22501    #[tokio::test]
22502    #[cfg(feature = "reactive-ws")]
22503    async fn preferred_flashblock_termination_preserves_canonical_delivery() {
22504        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22505        let filter = Filter::new().address(Address::repeat_byte(0x42));
22506        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22507            provider,
22508            SubscriberMode::PubSub,
22509            SubscriberConfig {
22510                preconfirmations: PreconfirmationMode::Preferred,
22511                reconnect: SubscriberReconnectConfig {
22512                    enabled: false,
22513                    ..SubscriberReconnectConfig::default()
22514                },
22515                ..SubscriberConfig::default()
22516            },
22517        )
22518        .with_provider_ref(ProviderRef::new("base-paid", 1));
22519        subscriber.chain_id = Some(8_453);
22520        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22521            provider_filter: filter.clone(),
22522            local_matcher: None,
22523            route_key: None,
22524        })];
22525        subscriber.interests = subscriber.base_interests.clone();
22526        subscriber.log_source_ids.insert(filter.clone(), 0);
22527        subscriber.next_log_source_id = 1;
22528        subscriber.sources_dirty = false;
22529
22530        let mut streams = SubscriberStreams::new();
22531        streams.push(
22532            SubscriberStreamSource::BaseFlashblocks,
22533            stream::once(async {
22534                SubscriberEvent::<Ethereum>::StreamTerminated(
22535                    SubscriberStreamSource::BaseFlashblocks,
22536                )
22537            })
22538            .boxed(),
22539        );
22540        streams.push(
22541            SubscriberStreamSource::PubSubLog {
22542                id: 0,
22543                filter: filter.clone(),
22544            },
22545            stream::once(async {
22546                SubscriberEvent::<Ethereum>::Log {
22547                    source_id: 0,
22548                    log: rpc_log(false),
22549                }
22550            })
22551            .boxed(),
22552        );
22553        subscriber.state = AlloySubscriberState::Active(streams);
22554
22555        let invalidation = subscriber
22556            .next_scoped_batch()
22557            .await
22558            .expect("preferred termination does not fail")
22559            .expect("invalidation is delivered");
22560        assert!(invalidation.preconfirmation_invalidated());
22561
22562        let canonical = subscriber
22563            .next_scoped_batch()
22564            .await
22565            .expect("canonical stream remains healthy")
22566            .expect("canonical log is delivered");
22567        assert!(!canonical.preconfirmation_invalidated());
22568        assert_eq!(canonical.records().len(), 1);
22569        assert_eq!(
22570            canonical.records()[0].record.context.source,
22571            InputSource::Subscription
22572        );
22573    }
22574
22575    #[tokio::test]
22576    #[cfg(feature = "reactive-ws")]
22577    async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
22578        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22579        let filter = Filter::new().address(Address::repeat_byte(0x42));
22580        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22581            provider,
22582            SubscriberMode::PubSub,
22583            SubscriberConfig {
22584                preconfirmations: PreconfirmationMode::Preferred,
22585                reconnect: SubscriberReconnectConfig {
22586                    enabled: false,
22587                    ..SubscriberReconnectConfig::default()
22588                },
22589                ..SubscriberConfig::default()
22590            },
22591        )
22592        .with_provider_ref(ProviderRef::new("base-paid", 1));
22593        subscriber.chain_id = Some(8_453);
22594        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22595            provider_filter: filter.clone(),
22596            local_matcher: None,
22597            route_key: None,
22598        })];
22599        subscriber.interests = subscriber.base_interests.clone();
22600        subscriber.log_source_ids.insert(filter.clone(), 0);
22601        subscriber.next_log_source_id = 1;
22602        subscriber.sources_dirty = false;
22603
22604        let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
22605        let mut streams = SubscriberStreams::new();
22606        streams.push(
22607            canonical_source,
22608            stream::once(async {
22609                tokio::time::sleep(Duration::from_millis(1)).await;
22610                SubscriberEvent::<Ethereum>::Log {
22611                    source_id: 0,
22612                    log: rpc_log(false),
22613                }
22614            })
22615            .boxed(),
22616        );
22617        subscriber.state = AlloySubscriberState::Active(streams);
22618
22619        let source = SubscriberStreamSource::BaseFlashblocks;
22620        subscriber
22621            .pending_flashblock_reconnect_sources
22622            .push(source.clone());
22623        subscriber
22624            .pending_flashblock_reconnects
22625            .push(Box::pin(async move {
22626                (
22627                    source,
22628                    Err(SubscriberError::Provider(
22629                        "test reconnect window exhausted".to_owned(),
22630                    )),
22631                )
22632            }));
22633
22634        let canonical = subscriber
22635            .next_scoped_batch()
22636            .await
22637            .expect("preferred reconnect exhaustion does not fail")
22638            .expect("canonical log is delivered");
22639        assert_eq!(canonical.records().len(), 1);
22640        assert!(subscriber.pending_flashblock_reconnects.is_empty());
22641    }
22642
22643    #[test]
22644    fn backfilled_logs_skip_recent_subscription_duplicates() {
22645        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22646        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22647            provider,
22648            SubscriberMode::PubSub,
22649            SubscriberConfig::default(),
22650        );
22651        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22652            provider_filter: Filter::new()
22653                .address(Address::repeat_byte(0x42))
22654                .event_signature(B256::repeat_byte(0x01)),
22655            local_matcher: None,
22656            route_key: None,
22657        })];
22658
22659        let log = rpc_log(false);
22660        subscriber.enqueue_event(SubscriberEvent::Log {
22661            source_id: 0,
22662            log: log.clone(),
22663        });
22664        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
22665            source_id: 0,
22666            logs: vec![log],
22667        });
22668
22669        assert_eq!(subscriber.pending_records.len(), 1);
22670        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22671        assert_eq!(
22672            subscriber.pending_records[0].context.source,
22673            InputSource::Subscription
22674        );
22675    }
22676
22677    #[test]
22678    fn backfilled_logs_surface_with_backfill_source() {
22679        // A backfilled log with no prior subscription duplicate is delivered as
22680        // an `InputSource::Backfill` record (the positive side of the dedup test,
22681        // pinning the README's "marking recovered records as InputSource::Backfill"
22682        // claim — the only place that source is produced).
22683        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22684        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22685            provider,
22686            SubscriberMode::PubSub,
22687            SubscriberConfig::default(),
22688        );
22689        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22690            provider_filter: Filter::new()
22691                .address(Address::repeat_byte(0x42))
22692                .event_signature(B256::repeat_byte(0x01)),
22693            local_matcher: None,
22694            route_key: None,
22695        })];
22696
22697        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
22698            source_id: 0,
22699            logs: vec![rpc_log(false)],
22700        });
22701
22702        assert_eq!(subscriber.pending_records.len(), 1);
22703        assert_eq!(
22704            subscriber.pending_records[0].context.source,
22705            InputSource::Backfill
22706        );
22707        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22708    }
22709
22710    #[test]
22711    #[cfg(feature = "reactive-ws")]
22712    fn owner_removal_preserves_delivery_and_dedupe_state() {
22713        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22714        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22715            provider,
22716            SubscriberMode::PubSub,
22717            SubscriberConfig::default(),
22718        );
22719        subscriber
22720            .add_interest_owner(
22721                HandlerId::new("pool-a"),
22722                &[ReactiveInterest::Logs(LogInterest {
22723                    provider_filter: Filter::new()
22724                        .address(Address::repeat_byte(0x42))
22725                        .event_signature(B256::repeat_byte(0x01)),
22726                    local_matcher: None,
22727                    route_key: None,
22728                })],
22729            )
22730            .expect("register pool-a owner");
22731        subscriber
22732            .add_interest_owner(
22733                HandlerId::new("pool-b"),
22734                &[ReactiveInterest::Logs(LogInterest {
22735                    provider_filter: Filter::new()
22736                        .address(Address::repeat_byte(0x24))
22737                        .event_signature(B256::repeat_byte(0x02)),
22738                    local_matcher: None,
22739                    route_key: None,
22740                })],
22741            )
22742            .expect("register pool-b owner");
22743
22744        // Allocate source ids the way live stream setup would (pool-a -> id 0),
22745        // so the injected delivery anchor hangs off a referenced filter.
22746        let sources = subscriber.stream_sources().expect("stream sources");
22747        subscriber.enqueue_event(SubscriberEvent::Log {
22748            source_id: 0,
22749            log: rpc_log(false),
22750        });
22751        let mut streams = SubscriberStreams::new();
22752        streams.push(
22753            sources[0].clone(),
22754            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22755        );
22756        subscriber.state = AlloySubscriberState::Active(streams);
22757        assert_eq!(subscriber.pending_records.len(), 1);
22758        assert_eq!(subscriber.recent_input_refs.len(), 1);
22759        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22760
22761        let removed = subscriber
22762            .remove_interest_owner(&HandlerId::new("pool-b"))
22763            .expect("pool-b should be removed");
22764
22765        assert_eq!(removed.len(), 1);
22766        assert_eq!(subscriber.pending_records.len(), 1);
22767        assert_eq!(subscriber.recent_input_refs.len(), 1);
22768        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22769        assert!(
22770            subscriber
22771                .owner_interests(&HandlerId::new("pool-a"))
22772                .is_some()
22773        );
22774        assert!(
22775            subscriber
22776                .owner_interests(&HandlerId::new("pool-b"))
22777                .is_none()
22778        );
22779        assert_eq!(subscriber.registered_interests().len(), 1);
22780    }
22781
22782    #[test]
22783    #[cfg(feature = "reactive-ws")]
22784    fn owner_log_sources_fan_in_across_owners() {
22785        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22786        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22787            provider,
22788            SubscriberMode::PubSub,
22789            SubscriberConfig::default(),
22790        );
22791        subscriber
22792            .add_interest_owner(
22793                HandlerId::new("pool-a"),
22794                &[ReactiveInterest::Logs(LogInterest {
22795                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
22796                    local_matcher: None,
22797                    route_key: None,
22798                })],
22799            )
22800            .expect("register pool-a owner");
22801
22802        let initial_sources = subscriber.stream_sources().expect("initial sources");
22803        assert_eq!(initial_sources.len(), 1);
22804        let pool_a_source = initial_sources[0].clone();
22805        assert!(matches!(
22806            &pool_a_source,
22807            SubscriberStreamSource::PubSubLog { id: 0, .. }
22808        ));
22809
22810        subscriber
22811            .add_interest_owner(
22812                HandlerId::new("pool-b"),
22813                &[ReactiveInterest::Logs(LogInterest {
22814                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
22815                    local_matcher: None,
22816                    route_key: None,
22817                })],
22818            )
22819            .expect("register pool-b owner");
22820
22821        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
22822        assert_eq!(
22823            expanded_sources.len(),
22824            1,
22825            "compatible owner filters should share one provider subscription"
22826        );
22827        assert!(
22828            !expanded_sources[0].same_key(&pool_a_source),
22829            "the provider-facing superset changes while owner routing remains exact"
22830        );
22831
22832        subscriber
22833            .remove_interest_owner(&HandlerId::new("pool-b"))
22834            .expect("pool-b should be removed");
22835        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
22836        assert_eq!(trimmed_sources.len(), 1);
22837        assert!(trimmed_sources[0].same_key(&pool_a_source));
22838    }
22839
22840    #[test]
22841    #[cfg(feature = "reactive-ws")]
22842    fn provider_log_fan_in_respects_address_ceiling() {
22843        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22844        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22845            provider,
22846            SubscriberMode::PubSub,
22847            SubscriberConfig {
22848                max_log_addresses_per_subscription: 2,
22849                ..SubscriberConfig::default()
22850            },
22851        );
22852        for index in 0..5 {
22853            subscriber
22854                .add_interest_owner(
22855                    HandlerId::new(format!("pool-{index}")),
22856                    &[log_interest_for(index + 1)],
22857                )
22858                .expect("register pool owner");
22859        }
22860
22861        let sources = subscriber.stream_sources().expect("stream sources");
22862        assert_eq!(sources.len(), 3);
22863        let mut address_counts: Vec<_> = sources
22864            .iter()
22865            .map(|source| match source {
22866                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
22867                _ => panic!("expected log source"),
22868            })
22869            .collect();
22870        address_counts.sort_unstable();
22871        assert_eq!(address_counts, vec![1, 2, 2]);
22872    }
22873
22874    #[tokio::test(flavor = "multi_thread")]
22875    #[cfg(feature = "reactive-ws")]
22876    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
22877        let asserter = Asserter::new();
22878        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22879        asserter.push_success(&vec![rpc_log(false)]);
22880        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22881        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22882        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22883            provider,
22884            SubscriberMode::PubSub,
22885            SubscriberConfig::default(),
22886        );
22887        subscriber
22888            .add_interest_owner_with_backfill(
22889                HandlerId::new("pool-a"),
22890                &[ReactiveInterest::Logs(LogInterest {
22891                    provider_filter: Filter::new()
22892                        .address(Address::repeat_byte(0x42))
22893                        .event_signature(B256::repeat_byte(0x01)),
22894                    local_matcher: None,
22895                    route_key: None,
22896                })],
22897                SubscriberBackfill::range(1, 7),
22898            )
22899            .expect("register pool-a with backfill");
22900
22901        subscriber
22902            .drain_pending_backfills()
22903            .await
22904            .expect("owner backfill should drain");
22905
22906        assert_eq!(subscriber.pending_records.len(), 1);
22907        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22908    }
22909
22910    #[tokio::test(flavor = "multi_thread")]
22911    async fn subscriber_streams_poll_ready_sources_round_robin() {
22912        let first_hash = B256::repeat_byte(0x01);
22913        let second_hash = B256::repeat_byte(0x02);
22914        let mut streams = SubscriberStreams::new();
22915        streams.push(
22916            SubscriberStreamSource::PubSubPendingHashes,
22917            stream::iter([
22918                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
22919                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
22920            ])
22921            .boxed(),
22922        );
22923        streams.push(
22924            SubscriberStreamSource::PubSubBlockHeaders,
22925            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
22926                .boxed(),
22927        );
22928
22929        assert!(matches!(
22930            streams.next().await,
22931            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
22932        ));
22933        assert!(matches!(
22934            streams.next().await,
22935            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
22936        ));
22937    }
22938
22939    #[tokio::test(flavor = "multi_thread")]
22940    #[cfg(feature = "reactive-ws")]
22941    async fn owner_updates_ensure_streams_without_full_reset() {
22942        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22943        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22944            provider,
22945            SubscriberMode::PubSub,
22946            SubscriberConfig::default(),
22947        );
22948        subscriber.chain_id = Some(1);
22949        subscriber
22950            .register_interests(&[ReactiveInterest::PendingTransactions(
22951                PendingTxInterest::default(),
22952            )])
22953            .await
22954            .expect("register base pending interest");
22955        subscriber
22956            .add_interest_owner(
22957                HandlerId::new("headers"),
22958                &[ReactiveInterest::Blocks(BlockInterest::default())],
22959            )
22960            .expect("register header owner");
22961
22962        let mut streams = SubscriberStreams::new();
22963        streams.push(
22964            SubscriberStreamSource::PubSubPendingHashes,
22965            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22966        );
22967        streams.push(
22968            SubscriberStreamSource::PubSubBlockHeaders,
22969            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22970        );
22971        subscriber.state = AlloySubscriberState::Active(streams);
22972
22973        subscriber
22974            .remove_interest_owner(&HandlerId::new("headers"))
22975            .expect("header owner should be removed");
22976        assert!(matches!(
22977            &subscriber.state,
22978            AlloySubscriberState::Active(streams) if streams.len() == 2
22979        ));
22980
22981        subscriber
22982            .ensure_streams()
22983            .await
22984            .expect("pure removal reconciliation should not touch provider");
22985
22986        assert!(matches!(
22987            &subscriber.state,
22988            AlloySubscriberState::Active(streams)
22989                if streams.len() == 1
22990                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
22991                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
22992        ));
22993
22994        subscriber
22995            .add_interest_owner(
22996                HandlerId::new("headers"),
22997                &[ReactiveInterest::Blocks(BlockInterest::default())],
22998            )
22999            .expect("re-add header owner");
23000        assert!(matches!(
23001            &subscriber.state,
23002            AlloySubscriberState::Active(streams) if streams.len() == 1
23003        ));
23004    }
23005
23006    #[tokio::test(flavor = "multi_thread")]
23007    #[cfg(feature = "reactive-polling")]
23008    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
23009        let asserter = Asserter::new();
23010        asserter.push_success(&U256::from(1));
23011        asserter.push_failure_msg("second filter connection failed");
23012        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
23013        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23014            provider,
23015            SubscriberMode::Polling,
23016            SubscriberConfig {
23017                max_log_addresses_per_subscription: 1,
23018                ..SubscriberConfig::default()
23019            },
23020        );
23021        subscriber.chain_id = Some(1);
23022        subscriber
23023            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
23024            .await
23025            .expect("register two independently connected filters");
23026
23027        let error = subscriber
23028            .ensure_streams()
23029            .await
23030            .expect_err("second provider connection is forced to fail");
23031        assert!(matches!(error, SubscriberError::Provider(_)));
23032        assert!(subscriber.sources_dirty);
23033        let retained_streams = match &subscriber.state {
23034            AlloySubscriberState::Active(streams) => Some(streams.len()),
23035            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
23036        };
23037        assert_eq!(
23038            retained_streams,
23039            Some(1),
23040            "first connection must survive later error {error:?}; revision {}",
23041            subscriber.stream_revision
23042        );
23043
23044        asserter.push_success(&U256::from(2));
23045        subscriber
23046            .ensure_streams()
23047            .await
23048            .expect("retry connects only the missing source");
23049        assert!(!subscriber.sources_dirty);
23050        assert!(matches!(
23051            &subscriber.state,
23052            AlloySubscriberState::Active(streams) if streams.len() == 2
23053        ));
23054        assert!(asserter.read_q().is_empty());
23055    }
23056
23057    #[tokio::test(flavor = "multi_thread")]
23058    #[cfg(feature = "reactive-ws")]
23059    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
23060        let asserter = Asserter::new();
23061        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
23062        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23063            provider,
23064            SubscriberMode::PubSub,
23065            SubscriberConfig::default(),
23066        );
23067        subscriber.chain_id = Some(1);
23068        subscriber
23069            .register_interests(&[log_interest_for(0x43)])
23070            .await
23071            .expect("register log source");
23072        let source = subscriber
23073            .stream_sources()
23074            .expect("one desired source")
23075            .pop()
23076            .expect("log source");
23077        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
23078            panic!("expected pubsub log source")
23079        };
23080        subscriber.last_seen_log_blocks.insert(id, 6);
23081
23082        {
23083            let source = SubscriberStreamSource::PubSubLog {
23084                id,
23085                filter: subscriber
23086                    .log_stream_filters()
23087                    .pop()
23088                    .expect("provider filter"),
23089            };
23090            let interrupted = async {
23091                subscriber.install_source_stream(
23092                    source.clone(),
23093                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
23094                );
23095                subscriber.queue_source_backfill(source);
23096                subscriber.sources_dirty = true;
23097                futures::future::pending::<()>().await;
23098            };
23099            futures::pin_mut!(interrupted);
23100            poll_fn(|cx| {
23101                assert!(interrupted.as_mut().poll(cx).is_pending());
23102                std::task::Poll::Ready(())
23103            })
23104            .await;
23105        }
23106
23107        assert_eq!(subscriber.pending_source_backfills.len(), 1);
23108        assert!(matches!(
23109            &subscriber.state,
23110            AlloySubscriberState::Active(streams) if streams.len() == 1
23111        ));
23112
23113        asserter.push_success(&7u64);
23114        asserter.push_success(&Vec::<Log>::new());
23115        subscriber
23116            .ensure_streams()
23117            .await
23118            .expect("retry completes only the pending historical window");
23119
23120        assert!(subscriber.pending_source_backfills.is_empty());
23121        assert!(!subscriber.sources_dirty);
23122        assert!(matches!(
23123            &subscriber.state,
23124            AlloySubscriberState::Active(streams) if streams.len() == 1
23125        ));
23126        assert!(asserter.read_q().is_empty());
23127    }
23128
23129    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
23130    #[cfg(any(
23131        feature = "raw-flashblocks-json",
23132        feature = "reactive-polling",
23133        feature = "reactive-ws"
23134    ))]
23135    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
23136        ReactiveInterest::Logs(LogInterest {
23137            provider_filter: Filter::new()
23138                .address(Address::repeat_byte(0x42))
23139                .event_signature(B256::repeat_byte(0x01)),
23140            local_matcher: None,
23141            route_key: None,
23142        })
23143    }
23144
23145    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
23146    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
23147        ReactiveInterest::Logs(LogInterest {
23148            provider_filter: Filter::new().address(Address::repeat_byte(address)),
23149            local_matcher: None,
23150            route_key: None,
23151        })
23152    }
23153
23154    // B1: a transient provider error must not consume the queued backfill — the
23155    // missed window has to survive for the next poll to retry.
23156    #[tokio::test(flavor = "multi_thread")]
23157    #[cfg(feature = "reactive-ws")]
23158    async fn drain_backfill_retains_queue_entry_on_provider_error() {
23159        let asserter = Asserter::new();
23160        asserter.push_failure_msg("rate limited");
23161        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
23162        asserter.push_success(&vec![rpc_log(false)]);
23163        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
23164        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
23165        let mut subscriber = AlloySubscriber::new(
23166            provider,
23167            SubscriberMode::PubSub,
23168            SubscriberConfig::default(),
23169        );
23170        subscriber
23171            .add_interest_owner_with_backfill(
23172                HandlerId::new("pool"),
23173                &[log_interest_matching_rpc_log()],
23174                SubscriberBackfill::range(1, 7),
23175            )
23176            .expect("register owner with backfill");
23177        assert_eq!(subscriber.pending_backfills.len(), 1);
23178
23179        let first = subscriber.drain_pending_backfills().await;
23180        assert!(first.is_err(), "provider failure should surface");
23181        assert_eq!(
23182            subscriber.pending_backfills.len(),
23183            1,
23184            "failed fetch must leave the backfill queued for retry"
23185        );
23186        assert!(subscriber.pending_records.is_empty());
23187
23188        subscriber
23189            .drain_pending_backfills()
23190            .await
23191            .expect("retry should succeed");
23192        assert!(subscriber.pending_backfills.is_empty());
23193        assert_eq!(subscriber.pending_records.len(), 1);
23194    }
23195
23196    // B3: a zero-log backfill window still advances the delivery anchor to its
23197    // upper bound, so a later reconnect catches up from the right block.
23198    #[tokio::test(flavor = "multi_thread")]
23199    #[cfg(feature = "reactive-ws")]
23200    async fn drain_backfill_seeds_anchor_on_empty_window() {
23201        let asserter = Asserter::new();
23202        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
23203        asserter.push_success(&Vec::<Log>::new());
23204        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
23205        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
23206        let mut subscriber = AlloySubscriber::new(
23207            provider,
23208            SubscriberMode::PubSub,
23209            SubscriberConfig::default(),
23210        );
23211        subscriber
23212            .add_interest_owner_with_backfill(
23213                HandlerId::new("pool"),
23214                &[log_interest_matching_rpc_log()],
23215                SubscriberBackfill::range(1, 42),
23216            )
23217            .expect("register owner with backfill");
23218
23219        subscriber
23220            .drain_pending_backfills()
23221            .await
23222            .expect("empty backfill should drain");
23223
23224        assert!(subscriber.pending_records.is_empty());
23225        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
23226            .pop()
23227            .unwrap();
23228        assert_eq!(
23229            subscriber.log_anchor(&filter),
23230            Some(42),
23231            "empty window must still seed the anchor at its upper bound"
23232        );
23233    }
23234
23235    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
23236    // the provider head and seeds the anchor there.
23237    #[tokio::test(flavor = "multi_thread")]
23238    #[cfg(feature = "reactive-ws")]
23239    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
23240        let asserter = Asserter::new();
23241        asserter.push_success(&100u64); // get_block_number
23242        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
23243        asserter.push_success(&Vec::<Log>::new()); // get_logs
23244        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
23245        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
23246        let mut subscriber = AlloySubscriber::new(
23247            provider,
23248            SubscriberMode::PubSub,
23249            SubscriberConfig::default(),
23250        );
23251        subscriber
23252            .add_interest_owner_with_backfill(
23253                HandlerId::new("pool"),
23254                &[log_interest_matching_rpc_log()],
23255                SubscriberBackfill::from_block(10),
23256            )
23257            .expect("register owner with open-ended backfill");
23258
23259        subscriber
23260            .drain_pending_backfills()
23261            .await
23262            .expect("open-ended backfill should drain");
23263
23264        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
23265            .pop()
23266            .unwrap();
23267        assert_eq!(subscriber.log_anchor(&filter), Some(100));
23268    }
23269
23270    // B2: two owners requesting the same filter shape share exactly one live
23271    // source (and thus one anchor), rather than double-subscribing.
23272    #[test]
23273    #[cfg(feature = "reactive-ws")]
23274    fn duplicate_filters_across_owners_map_to_single_source() {
23275        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23276        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23277            provider,
23278            SubscriberMode::PubSub,
23279            SubscriberConfig::default(),
23280        );
23281        subscriber
23282            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
23283            .expect("register pool-a");
23284        subscriber
23285            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
23286            .expect("register pool-b with identical filter");
23287
23288        assert_eq!(
23289            subscriber.log_stream_filters().len(),
23290            1,
23291            "identical filters across owners must collapse to one"
23292        );
23293        let sources = subscriber.stream_sources().expect("stream sources");
23294        assert_eq!(sources.len(), 1);
23295    }
23296
23297    // B4: removing an owner retires the source-id and anchor bookkeeping for
23298    // filters no other owner references, so long-lived churn cannot leak.
23299    #[test]
23300    #[cfg(feature = "reactive-ws")]
23301    fn owner_removal_prunes_source_ids_and_anchors() {
23302        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23303        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23304            provider,
23305            SubscriberMode::PubSub,
23306            SubscriberConfig::default(),
23307        );
23308        subscriber
23309            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
23310            .expect("register pool-a");
23311        subscriber
23312            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
23313            .expect("register pool-b");
23314
23315        // Allocate ids and simulate delivery anchors on both.
23316        let _ = subscriber.stream_sources().expect("stream sources");
23317        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23318        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
23319        let id_a = subscriber.log_source_id(&filter_a);
23320        let id_b = subscriber.log_source_id(&filter_b);
23321        subscriber.last_seen_log_blocks.insert(id_a, 10);
23322        subscriber.last_seen_log_blocks.insert(id_b, 20);
23323        assert_eq!(
23324            subscriber.log_source_ids.len(),
23325            3,
23326            "one provider fan-in id plus two explicitly seeded logical ids"
23327        );
23328
23329        subscriber
23330            .remove_interest_owner(&HandlerId::new("pool-b"))
23331            .expect("remove pool-b");
23332
23333        assert_eq!(
23334            subscriber.log_source_ids.len(),
23335            1,
23336            "pool-b's filter id should be retired"
23337        );
23338        assert!(subscriber.log_source_ids.contains_key(&filter_a));
23339        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
23340        assert_eq!(
23341            subscriber.last_seen_log_blocks.get(&id_b),
23342            None,
23343            "pool-b's anchor should be pruned"
23344        );
23345    }
23346
23347    // D1: growing an owner's filter set (a new pool on an existing adapter)
23348    // changes the merged filter shape; the new shape must inherit the old
23349    // anchor via an automatic continuity backfill, or logs between the last
23350    // delivery and the new subscription are silently lost.
23351    #[test]
23352    #[cfg(feature = "reactive-ws")]
23353    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
23354        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23355        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23356            provider,
23357            SubscriberMode::PubSub,
23358            SubscriberConfig::default(),
23359        );
23360        subscriber
23361            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23362            .expect("register amm with pool A");
23363
23364        // Simulate the owner's single merged filter having delivered up to
23365        // block 50.
23366        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23367        let id_a = subscriber.log_source_id(&filter_a);
23368        subscriber.last_seen_log_blocks.insert(id_a, 50);
23369
23370        // Grow the owner to also watch pool B (same block option -> merges into
23371        // one {A,B} filter, a new shape).
23372        subscriber
23373            .add_interest_owner(
23374                HandlerId::new("amm"),
23375                &[log_interest_for(0xaa), log_interest_for(0xbb)],
23376            )
23377            .expect("grow amm to pools A+B");
23378
23379        assert_eq!(
23380            subscriber.pending_backfills.len(),
23381            1,
23382            "the changed merged filter should queue exactly one continuity backfill"
23383        );
23384        let queued = &subscriber.pending_backfills[0];
23385        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
23386        assert_eq!(queued.backfill.start_block(), 50);
23387        assert_eq!(
23388            queued.backfill.end_block(),
23389            None,
23390            "continuity backfill runs open-ended to the current head"
23391        );
23392    }
23393
23394    // D1 negative: replacing an owner's interests with the identical shape must
23395    // NOT re-fetch — the filter kept its anchor and its live stream.
23396    #[test]
23397    #[cfg(feature = "reactive-ws")]
23398    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
23399        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23400        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23401            provider,
23402            SubscriberMode::PubSub,
23403            SubscriberConfig::default(),
23404        );
23405        subscriber
23406            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23407            .expect("register amm");
23408        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23409        let id_a = subscriber.log_source_id(&filter_a);
23410        subscriber.last_seen_log_blocks.insert(id_a, 50);
23411
23412        subscriber
23413            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23414            .expect("re-register identical interests");
23415
23416        assert!(
23417            subscriber.pending_backfills.is_empty(),
23418            "an unchanged filter shape must not queue continuity backfill"
23419        );
23420    }
23421
23422    // D5 interaction: an explicit open-ended backfill starting at or below the
23423    // owner's prior anchor already covers the continuity window, so no extra
23424    // continuity backfill is queued (no redundant double fetch).
23425    #[test]
23426    #[cfg(feature = "reactive-ws")]
23427    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
23428        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23429        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23430            provider,
23431            SubscriberMode::PubSub,
23432            SubscriberConfig::default(),
23433        );
23434        subscriber
23435            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23436            .expect("register amm");
23437        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23438        let id_a = subscriber.log_source_id(&filter_a);
23439        subscriber.last_seen_log_blocks.insert(id_a, 50);
23440
23441        // Grow with an explicit deep backfill from block 10 (< anchor 50).
23442        subscriber
23443            .add_interest_owner_with_backfill(
23444                HandlerId::new("amm"),
23445                &[log_interest_for(0xaa), log_interest_for(0xbb)],
23446                SubscriberBackfill::from_block(10),
23447            )
23448            .expect("grow amm with explicit deep backfill");
23449
23450        assert_eq!(
23451            subscriber.pending_backfills.len(),
23452            1,
23453            "only the explicit backfill should be queued; continuity is subsumed"
23454        );
23455        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
23456    }
23457
23458    // The dirty flag gates reconciliation: when nothing changed since the last
23459    // reconcile, `ensure_streams` must not touch the provider or the state.
23460    #[tokio::test(flavor = "multi_thread")]
23461    #[cfg(feature = "reactive-ws")]
23462    async fn ensure_streams_is_noop_when_not_dirty() {
23463        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23464        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23465            provider,
23466            SubscriberMode::PubSub,
23467            SubscriberConfig::default(),
23468        );
23469        // An interest that WOULD require a new block-header source...
23470        subscriber
23471            .add_interest_owner(
23472                HandlerId::new("headers"),
23473                &[ReactiveInterest::Blocks(BlockInterest::default())],
23474            )
23475            .expect("register header owner");
23476        // ...but we mark bookkeeping clean and start from Empty.
23477        subscriber.state = AlloySubscriberState::Empty;
23478        subscriber.sources_dirty = false;
23479
23480        subscriber
23481            .ensure_streams()
23482            .await
23483            .expect("clean reconcile must be a no-op");
23484
23485        assert!(
23486            matches!(subscriber.state, AlloySubscriberState::Empty),
23487            "not-dirty ensure_streams must not connect new sources"
23488        );
23489    }
23490}
23491
23492fn resolve_subscriber_transport(
23493    mode: SubscriberMode,
23494) -> Result<SubscriberTransport, SubscriberError> {
23495    match mode {
23496        SubscriberMode::PubSub => {
23497            #[cfg(feature = "reactive-ws")]
23498            {
23499                Ok(SubscriberTransport::PubSub)
23500            }
23501            #[cfg(not(feature = "reactive-ws"))]
23502            {
23503                Err(SubscriberError::Unsupported(
23504                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
23505                ))
23506            }
23507        }
23508        SubscriberMode::Polling => {
23509            #[cfg(feature = "reactive-polling")]
23510            {
23511                Ok(SubscriberTransport::Polling)
23512            }
23513            #[cfg(not(feature = "reactive-polling"))]
23514            {
23515                Err(SubscriberError::Unsupported(
23516                    "AlloySubscriber polling mode requires the reactive-polling feature",
23517                ))
23518            }
23519        }
23520        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
23521    }
23522}
23523
23524fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
23525    #[cfg(feature = "reactive-ws")]
23526    {
23527        Ok(SubscriberTransport::PubSub)
23528    }
23529
23530    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
23531    {
23532        Ok(SubscriberTransport::Polling)
23533    }
23534
23535    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
23536    {
23537        Err(SubscriberError::Unsupported(
23538            "AlloySubscriber requires either reactive-ws or reactive-polling",
23539        ))
23540    }
23541}
23542
23543fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
23544    if config.preconfirmations != PreconfirmationMode::Disabled
23545        && config.canonical_head_poll_interval.is_zero()
23546    {
23547        return Err(SubscriberError::InvalidConfig(
23548            "SubscriberConfig::canonical_head_poll_interval must be greater than zero",
23549        ));
23550    }
23551    if config.preconfirmations != PreconfirmationMode::Disabled
23552        && config.canonical_head_request_timeout.is_zero()
23553    {
23554        return Err(SubscriberError::InvalidConfig(
23555            "SubscriberConfig::canonical_head_request_timeout must be greater than zero",
23556        ));
23557    }
23558    if config.preconfirmations != PreconfirmationMode::Disabled
23559        && config.flashblock_poll_interval.is_zero()
23560    {
23561        return Err(SubscriberError::InvalidConfig(
23562            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
23563        ));
23564    }
23565    if config.preconfirmations != PreconfirmationMode::Disabled
23566        && config.max_consecutive_flashblock_poll_failures == 0
23567    {
23568        return Err(SubscriberError::InvalidConfig(
23569            "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
23570        ));
23571    }
23572    if config.preconfirmations != PreconfirmationMode::Disabled
23573        && config.max_pending_transaction_receipts_per_tick == 0
23574    {
23575        return Err(SubscriberError::InvalidConfig(
23576            "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
23577        ));
23578    }
23579    if config.preconfirmations != PreconfirmationMode::Disabled
23580        && config.max_flashblock_rpc_requests_per_second == 0
23581    {
23582        return Err(SubscriberError::InvalidConfig(
23583            "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
23584        ));
23585    }
23586    if config.max_batch_size == 0 {
23587        return Err(SubscriberError::InvalidConfig(
23588            "SubscriberConfig::max_batch_size must be greater than zero",
23589        ));
23590    }
23591    if config.max_log_addresses_per_subscription == 0 {
23592        return Err(SubscriberError::InvalidConfig(
23593            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
23594        ));
23595    }
23596    if config.max_pending_records == 0 {
23597        return Err(SubscriberError::InvalidConfig(
23598            "SubscriberConfig::max_pending_records must be greater than zero",
23599        ));
23600    }
23601    if config.max_pending_backfills == 0 {
23602        return Err(SubscriberError::InvalidConfig(
23603            "SubscriberConfig::max_pending_backfills must be greater than zero",
23604        ));
23605    }
23606    if config.max_backfill_log_bytes == 0 {
23607        return Err(SubscriberError::InvalidConfig(
23608            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
23609        ));
23610    }
23611    if config.max_reconcile_requests_in_flight == 0 {
23612        return Err(SubscriberError::InvalidConfig(
23613            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
23614        ));
23615    }
23616    if config.reconnect.enabled {
23617        if config.reconnect.retry_delay > config.reconnect.max_delay {
23618            return Err(SubscriberError::InvalidConfig(
23619                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
23620            ));
23621        }
23622        if matches!(config.reconnect.max_attempts, Some(0)) {
23623            return Err(SubscriberError::InvalidConfig(
23624                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
23625            ));
23626        }
23627    }
23628    Ok(())
23629}
23630
23631fn validate_supported_interests<N: Network>(
23632    mode: SubscriberMode,
23633    config: &SubscriberConfig,
23634    interests: &[ReactiveInterest<N>],
23635) -> Result<(), SubscriberError> {
23636    let transport = resolve_subscriber_transport(mode)?;
23637
23638    for interest in interests {
23639        match interest {
23640            ReactiveInterest::Logs(_) => {}
23641            ReactiveInterest::PendingTransactions(interest)
23642                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
23643            ReactiveInterest::PendingTransactions(_) => {
23644                return Err(SubscriberError::Unsupported(
23645                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
23646                ));
23647            }
23648            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
23649                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
23650                (_, BlockInterestMode::FullBlock) => {
23651                    return Err(SubscriberError::Unsupported(
23652                        "AlloySubscriber full block streams are not implemented in this transport slice",
23653                    ));
23654                }
23655                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
23656                    return Err(SubscriberError::Unsupported(
23657                        "AlloySubscriber polling block streams are not implemented in this transport slice",
23658                    ));
23659                }
23660            },
23661        }
23662    }
23663
23664    Ok(())
23665}
23666
23667fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
23668    let mut filters = Vec::new();
23669    for interest in interests {
23670        if let ReactiveInterest::Logs(interest) = interest {
23671            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
23672        }
23673    }
23674    filters
23675}
23676
23677fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
23678    interests.iter().any(|interest| {
23679        matches!(
23680            interest,
23681            ReactiveInterest::Blocks(BlockInterest {
23682                mode: BlockInterestMode::Header,
23683            })
23684        )
23685    })
23686}
23687
23688fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
23689    interests.iter().any(|interest| {
23690        matches!(
23691            interest,
23692            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
23693        )
23694    })
23695}
23696
23697fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
23698    interests.iter().any(|interest| {
23699        matches!(
23700            interest,
23701            ReactiveInterest::Logs(interest) if interest.matches(log)
23702        )
23703    })
23704}
23705
23706fn validate_owner_backfill_logs(
23707    logs: &[Log],
23708    from_block: u64,
23709    through: &BlockRef,
23710) -> Result<(), SubscriberOwnerError> {
23711    for log in logs {
23712        if log.removed {
23713            return Err(SubscriberOwnerError::InvalidBackfillLog(
23714                "removed log in canonical catch-up",
23715            ));
23716        }
23717        let number = log
23718            .block_number
23719            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23720                "log missing block number",
23721            ))?;
23722        let hash = log
23723            .block_hash
23724            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23725                "log missing block hash",
23726            ))?;
23727        log.transaction_hash
23728            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23729                "log missing transaction hash",
23730            ))?;
23731        log.transaction_index
23732            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23733                "log missing transaction index",
23734            ))?;
23735        log.log_index
23736            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23737                "log missing log index",
23738            ))?;
23739        if number < from_block || number > through.number {
23740            return Err(SubscriberOwnerError::InvalidBackfillLog(
23741                "log outside requested block range",
23742            ));
23743        }
23744        if number == through.number && hash != through.hash {
23745            return Err(SubscriberOwnerError::InvalidBackfillLog(
23746                "target-block log hash mismatch",
23747            ));
23748        }
23749    }
23750    Ok(())
23751}
23752
23753fn validate_backfill_resource_limits(
23754    logs: &[Log],
23755    max_logs: usize,
23756    max_log_bytes: usize,
23757) -> Result<usize, SubscriberError> {
23758    if logs.len() > max_logs {
23759        return Err(SubscriberError::ResourceExhausted(format!(
23760            "historical response returned {} logs, above the configured limit of {max_logs}",
23761            logs.len()
23762        )));
23763    }
23764    let bytes = logs.iter().fold(0usize, |total, log| {
23765        // Include fixed address/block/transaction/index fields in addition to
23766        // the variable topic and data payload. This is deliberately a stable
23767        // conservative accounting unit rather than Rust heap-layout size.
23768        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
23769        total
23770            .saturating_add(fixed)
23771            .saturating_add(log.topics().len().saturating_mul(32))
23772            .saturating_add(log.inner.data.data.len())
23773    });
23774    if bytes > max_log_bytes {
23775        return Err(SubscriberError::ResourceExhausted(format!(
23776            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
23777        )));
23778    }
23779    Ok(bytes)
23780}
23781
23782async fn fetch_provider_block_ref<P, N>(
23783    provider: &P,
23784    number: u64,
23785) -> Result<BlockRef, SubscriberError>
23786where
23787    P: Provider<N> + Send + Sync,
23788    N: Network,
23789{
23790    let block = provider
23791        .get_block_by_number(BlockNumberOrTag::Number(number))
23792        .await
23793        .map_err(provider_error)?
23794        .ok_or_else(|| {
23795            SubscriberError::InvalidBackfill(format!(
23796                "canonical target block {number} is unavailable"
23797            ))
23798        })?;
23799    let header = block.header();
23800    Ok(BlockRef {
23801        number: header.number(),
23802        hash: header.hash(),
23803        parent_hash: Some(header.parent_hash()),
23804        timestamp: Some(header.timestamp()),
23805    })
23806}
23807
23808fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
23809    actual.number == expected.number
23810        && actual.hash == expected.hash
23811        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
23812        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
23813}
23814
23815fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
23816    let mut positions = HashMap::new();
23817    let mut block_hashes = HashMap::new();
23818    let mut transaction_hashes = HashMap::new();
23819    let mut transaction_positions = HashMap::new();
23820    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
23821    for log in logs {
23822        let number = log
23823            .block_number
23824            .expect("individual owner catch-up logs are validated before set validation");
23825        let block_hash = log
23826            .block_hash
23827            .expect("individual owner catch-up logs are validated before set validation");
23828        let transaction_hash = log
23829            .transaction_hash
23830            .expect("individual owner catch-up logs are validated before set validation");
23831        let transaction_index = log
23832            .transaction_index
23833            .expect("individual owner catch-up logs are validated before set validation");
23834        let log_index = log
23835            .log_index
23836            .expect("individual owner catch-up logs are validated before set validation");
23837        if block_hashes
23838            .insert(number, block_hash)
23839            .is_some_and(|prior| prior != block_hash)
23840        {
23841            return Err(SubscriberOwnerError::InvalidBackfillLog(
23842                "conflicting block identity in canonical catch-up",
23843            ));
23844        }
23845        if let Some(previous) = positions.insert((number, log_index), log)
23846            && previous != log
23847        {
23848            return Err(SubscriberOwnerError::InvalidBackfillLog(
23849                "conflicting logs at one canonical block position",
23850            ));
23851        }
23852        let conflicting_transaction = transaction_hashes
23853            .insert((number, transaction_index), transaction_hash)
23854            .is_some_and(|prior| prior != transaction_hash)
23855            || transaction_positions
23856                .insert((number, transaction_hash), transaction_index)
23857                .is_some_and(|prior| prior != transaction_index);
23858        if conflicting_transaction {
23859            return Err(SubscriberOwnerError::InvalidBackfillLog(
23860                "conflicting transaction identity at one canonical block position",
23861            ));
23862        }
23863        ordering
23864            .entry(number)
23865            .or_default()
23866            .push((log_index, transaction_index));
23867    }
23868    for positions in ordering.values_mut() {
23869        positions.sort_unstable();
23870        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
23871            return Err(SubscriberOwnerError::InvalidBackfillLog(
23872                "transaction and log positions disagree on canonical order",
23873            ));
23874        }
23875    }
23876    Ok(())
23877}
23878
23879fn merged_owner_reconcile_filters<N: Network>(
23880    plans: &[SubscriberOwnerReconcilePlan<N>],
23881    through: u64,
23882) -> Vec<SubscriberOwnerReconcileFilter> {
23883    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
23884    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
23885        let filters = by_start.entry(plan.from_block).or_default();
23886        filters.extend(
23887            log_filters(&plan.interests)
23888                .into_iter()
23889                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
23890        );
23891    }
23892
23893    let mut chunks = Vec::new();
23894    for (from_block, filters) in by_start {
23895        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
23896            let mut merged = Vec::new();
23897            for filter in filters {
23898                merge_log_subscription_filter(&mut merged, filter);
23899            }
23900            chunks.extend(
23901                merged
23902                    .into_iter()
23903                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
23904            );
23905        }
23906    }
23907    chunks
23908}
23909
23910fn merged_lazy_backfill_filters(
23911    filters: &[Filter],
23912    from_block: u64,
23913    through: u64,
23914) -> Vec<SubscriberOwnerReconcileFilter> {
23915    let mut requests = Vec::new();
23916    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
23917        let mut merged = Vec::new();
23918        for filter in filters {
23919            merge_log_subscription_filter(
23920                &mut merged,
23921                &filter.clone().from_block(from_block).to_block(through),
23922            );
23923        }
23924        requests.extend(
23925            merged
23926                .into_iter()
23927                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
23928        );
23929    }
23930    requests
23931}
23932
23933fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
23934    match error {
23935        SubscriberOwnerError::Subscriber(error) => error,
23936        error => SubscriberError::InvalidBackfill(error.to_string()),
23937    }
23938}
23939
23940fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
23941    let mut id = b"alloy-global-backfill-v1".to_vec();
23942    id.extend_from_slice(&backfill.start_block().to_be_bytes());
23943    id.extend_from_slice(&certified.number.to_be_bytes());
23944    id.extend_from_slice(certified.hash.as_slice());
23945    ChainControl::Barrier {
23946        id,
23947        block: Some(certified),
23948    }
23949}
23950
23951async fn fetch_owner_catchup<P, N>(
23952    provider: P,
23953    filters: Vec<SubscriberOwnerReconcileFilter>,
23954    retained: Vec<BlockRef>,
23955    through: BlockRef,
23956    options: SubscriberOwnerCatchupOptions,
23957) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
23958where
23959    P: Provider<N> + Send + Sync,
23960    N: Network,
23961{
23962    if !options.target_preverified {
23963        let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
23964    }
23965    let mut certified_positions = HashSet::new();
23966    for position in retained {
23967        let target_certifies_position = position == through
23968            || (position.number.checked_add(1) == Some(through.number)
23969                && through.parent_hash == Some(position.hash));
23970        if !target_certifies_position && certified_positions.insert(position) {
23971            let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
23972        }
23973    }
23974    let mut logs = Vec::new();
23975    let mut total_log_bytes = 0usize;
23976    let requests = stream::iter(filters.into_iter().map(|filter| {
23977        let provider = &provider;
23978        async move {
23979            let logs = provider
23980                .get_logs(&filter.filter)
23981                .await
23982                .map_err(provider_error)?;
23983            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
23984        }
23985    }))
23986    .buffer_unordered(options.max_requests_in_flight);
23987    futures::pin_mut!(requests);
23988    while let Some(result) = requests.next().await {
23989        let (from_block, fetched) = result?;
23990        let fetched_bytes =
23991            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
23992        validate_owner_backfill_logs(&fetched, from_block, &through)?;
23993        if logs.len().saturating_add(fetched.len()) > options.max_logs {
23994            return Err(SubscriberError::ResourceExhausted(format!(
23995                "bulk reconcile returned more than {} logs",
23996                options.max_logs
23997            ))
23998            .into());
23999        }
24000        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
24001        if total_log_bytes > options.max_log_bytes {
24002            return Err(SubscriberError::ResourceExhausted(format!(
24003                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
24004                options.max_log_bytes
24005            ))
24006            .into());
24007        }
24008        logs.extend(fetched);
24009    }
24010    validate_owner_backfill_log_set(&logs)?;
24011    let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
24012    Ok(SubscriberOwnerCatchup { logs, certified })
24013}
24014
24015async fn verify_provider_reconcile_target<P, N>(
24016    provider: &P,
24017    expected: &BlockRef,
24018) -> Result<BlockRef, SubscriberOwnerError>
24019where
24020    P: Provider<N> + Send + Sync,
24021    N: Network,
24022{
24023    let block = provider
24024        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
24025        .await
24026        .map_err(provider_error)?
24027        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
24028    let header = block.header();
24029    let actual = BlockRef {
24030        number: header.number(),
24031        hash: header.hash(),
24032        parent_hash: Some(header.parent_hash()),
24033        timestamp: Some(header.timestamp()),
24034    };
24035    let exact_parent = expected
24036        .parent_hash
24037        .is_none_or(|parent| Some(parent) == actual.parent_hash);
24038    let exact_timestamp = expected
24039        .timestamp
24040        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
24041    if actual.number != expected.number
24042        || actual.hash != expected.hash
24043        || !exact_parent
24044        || !exact_timestamp
24045    {
24046        return Err(SubscriberOwnerError::BlockMismatch {
24047            expected_number: expected.number,
24048            expected_hash: expected.hash,
24049            actual_number: actual.number,
24050            actual_hash: actual.hash,
24051        });
24052    }
24053    Ok(actual)
24054}
24055
24056fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
24057    let context = log_reactive_context(&log);
24058    ReactiveInputRecord::new(
24059        ReactiveInput::Log(log),
24060        ReactiveContext { source, ..context },
24061    )
24062}
24063
24064fn preconfirmed_log_input_record<N: Network>(
24065    log: Log,
24066    flashblock: FlashblockRef,
24067) -> ReactiveInputRecord<N> {
24068    let block = flashblock.block_ref();
24069    let provider = flashblock.provider.clone();
24070    ReactiveInputRecord::new(
24071        ReactiveInput::Log(log.clone()),
24072        ReactiveContext {
24073            chain_id: None,
24074            source: InputSource::Flashblocks,
24075            chain_status: ChainStatus::Preconfirmed {
24076                flashblock: Arc::new(flashblock),
24077            },
24078            block: Some(block),
24079            transaction_index: log.transaction_index,
24080            log_index: log.log_index,
24081        },
24082    )
24083    .with_provider(provider)
24084}
24085
24086fn log_reactive_context(log: &Log) -> ReactiveContext {
24087    let block = match (log.block_hash, log.block_number) {
24088        (Some(hash), Some(number)) => Some(BlockRef {
24089            number,
24090            hash,
24091            parent_hash: None,
24092            timestamp: log.block_timestamp,
24093        }),
24094        _ => None,
24095    };
24096
24097    let chain_status = match (&block, log.removed) {
24098        (Some(block), true) => ChainStatus::Reorged {
24099            dropped_from: *block,
24100        },
24101        (Some(block), false) => ChainStatus::Included {
24102            block: *block,
24103            confirmations: 0,
24104        },
24105        (None, _) => ChainStatus::Pending,
24106    };
24107
24108    ReactiveContext {
24109        chain_id: None,
24110        source: InputSource::Poll,
24111        chain_status,
24112        block,
24113        transaction_index: log.transaction_index,
24114        log_index: log.log_index,
24115    }
24116}
24117
24118fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
24119where
24120    N: Network,
24121{
24122    let block = BlockRef {
24123        number: header.number(),
24124        hash: HeaderResponseTrait::hash(&header),
24125        parent_hash: Some(header.parent_hash()),
24126        timestamp: Some(header.timestamp()),
24127    };
24128    ReactiveInputRecord::new(
24129        ReactiveInput::BlockHeader(header),
24130        ReactiveContext {
24131            chain_id: None,
24132            source: InputSource::Subscription,
24133            chain_status: ChainStatus::Included {
24134                block,
24135                confirmations: 0,
24136            },
24137            block: Some(block),
24138            transaction_index: None,
24139            log_index: None,
24140        },
24141    )
24142}
24143
24144fn pending_hash_input_record<N: Network>(
24145    hash: B256,
24146    source: InputSource,
24147) -> ReactiveInputRecord<N> {
24148    ReactiveInputRecord::new(
24149        ReactiveInput::PendingTxHash(hash),
24150        ReactiveContext {
24151            chain_id: None,
24152            source,
24153            chain_status: ChainStatus::Pending,
24154            block: None,
24155            transaction_index: None,
24156            log_index: None,
24157        },
24158    )
24159}
24160
24161#[cfg(feature = "reactive-ws")]
24162fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
24163    let encoded = serde_json::to_value(filter)
24164        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
24165    let serde_json::Value::Object(mut fields) = encoded else {
24166        return Err(SubscriberError::Provider(
24167            "Alloy log filter did not serialize as an object".into(),
24168        ));
24169    };
24170    fields.retain(|key, _| key == "address" || key == "topics");
24171    Ok(serde_json::Value::Object(fields))
24172}
24173
24174fn provider_error(error: impl fmt::Display) -> SubscriberError {
24175    SubscriberError::Provider(error.to_string())
24176}
24177
24178/// Subscriber error.
24179#[derive(Debug, thiserror::Error)]
24180#[non_exhaustive]
24181pub enum SubscriberError {
24182    /// Invalid subscriber configuration.
24183    #[error("{0}")]
24184    InvalidConfig(&'static str),
24185    /// Requested subscriber behavior is not implemented.
24186    #[error("{0}")]
24187    Unsupported(&'static str),
24188    /// The pinned provider lease reports a different chain identity.
24189    #[error("subscriber chain mismatch: expected {expected}, got {actual}")]
24190    ChainMismatch {
24191        /// Required chain id.
24192        expected: u64,
24193        /// Observed chain id.
24194        actual: u64,
24195    },
24196    /// Provider or transport error.
24197    #[error("provider error: {0}")]
24198    Provider(String),
24199    /// A provider returned malformed, out-of-range, or non-canonical lazy
24200    /// backfill data.
24201    #[error("invalid canonical backfill: {0}")]
24202    InvalidBackfill(String),
24203    /// A configured subscriber memory/concurrency boundary was exceeded.
24204    #[error("subscriber resource limit exceeded: {0}")]
24205    ResourceExhausted(String),
24206}