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    FlashblockInvalidation, FlashblockInvalidationReason, FlashblockSnapshot, FlashblockUpdate,
70    FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError, FlashblockUpdateSender,
71    RawJsonFlashblocksAdapter, RawJsonFlashblocksError, RawJsonFlashblocksLimits,
72};
73
74/// Input accepted by the reactive runtime.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum ReactiveInput<N: Network = Ethereum> {
77    /// A canonical or removed EVM log, using Alloy's RPC log type.
78    Log(Log),
79    /// A block header response for header-oriented handlers.
80    BlockHeader(N::HeaderResponse),
81    /// A full block response for block handlers that need transaction bodies.
82    FullBlock(N::BlockResponse),
83    /// A pending transaction hash.
84    PendingTxHash(B256),
85    /// A full pending transaction body.
86    PendingTx(N::TransactionResponse),
87}
88
89/// Context supplied with each [`ReactiveInput`].
90#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub struct ReactiveContext {
92    /// Chain id, when known.
93    pub chain_id: Option<u64>,
94    /// Where the input came from.
95    pub source: InputSource,
96    /// Lifecycle status of the input.
97    pub chain_status: ChainStatus,
98    /// Block metadata associated with the input, when known.
99    pub block: Option<BlockRef>,
100    /// Transaction index for log or transaction inputs.
101    pub transaction_index: Option<u64>,
102    /// Log index for log inputs.
103    pub log_index: Option<u64>,
104}
105
106/// Minimal block identity carried through reports.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
108pub struct BlockRef {
109    /// Block number.
110    pub number: u64,
111    /// Block hash.
112    pub hash: B256,
113    /// Parent hash, when known.
114    pub parent_hash: Option<B256>,
115    /// Block timestamp, when known.
116    pub timestamp: Option<u64>,
117}
118
119/// Stable provider identity attached to provider-originated input.
120///
121/// `generation` changes whenever a caller replaces or reconnects the concrete
122/// provider session behind the same configured endpoint. Follow-up reads can
123/// use this value to prefer the exact source that announced speculative state
124/// without putting URLs or credentials into event payloads.
125#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
126pub struct ProviderRef {
127    /// Operator-defined endpoint identity.
128    pub endpoint: EndpointId,
129    /// Concrete connection/session generation.
130    pub generation: u64,
131}
132
133impl ProviderRef {
134    /// Construct provider provenance for one connection generation.
135    pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
136        Self {
137            endpoint: endpoint.into(),
138            generation,
139        }
140    }
141}
142
143/// Identity of one cumulative pre-confirmed Flashblock snapshot.
144#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
145pub struct FlashblockRef {
146    /// Provider session that supplied this snapshot.
147    pub provider: ProviderRef,
148    /// Sequencer payload id shared by every Flashblock in the full block.
149    ///
150    /// Some provider wire shapes omit this indexed-payload identifier.
151    pub payload_id: Option<FixedBytes<8>>,
152    /// Zero-based Flashblock index, when exposed by the endpoint.
153    pub index: Option<u64>,
154    /// Pending block number represented by this cumulative snapshot.
155    pub block_number: u64,
156    /// Provider-generation-scoped commitment to this exact cumulative view.
157    ///
158    /// This is deliberately not a canonical or provider-reported block hash.
159    /// It remains non-zero even when a pending endpoint uses the zero hash
160    /// placeholder permitted by the Flashblocks specification.
161    pub content_hash: B256,
162    /// Non-placeholder partial block hash reported by the provider, when any.
163    pub partial_block_hash: Option<B256>,
164    /// Canonical parent of the pending block, when exposed.
165    pub parent_hash: Option<B256>,
166    /// State root after this cumulative snapshot, when exposed.
167    pub state_root: Option<B256>,
168    /// Transaction-trie root committed by a cumulative block-shaped preview.
169    pub transactions_root: Option<B256>,
170    /// Ordered cumulative transaction membership for this preview.
171    pub transaction_hashes: Vec<B256>,
172    /// Pending block timestamp, when exposed.
173    pub timestamp: Option<u64>,
174    /// Pending EIP-1559 base fee, when exposed.
175    pub base_fee_per_gas: Option<u64>,
176    /// Pending block beneficiary / fee recipient, when exposed.
177    pub beneficiary: Option<Address>,
178    /// Pending block randomness value, when exposed.
179    pub prevrandao: Option<B256>,
180    /// Pending block gas limit, when exposed.
181    pub gas_limit: Option<u64>,
182}
183
184impl FlashblockRef {
185    /// Convert the pre-confirmed identity into the block metadata used by
186    /// ordinary log routing. The hash is the provider-generation-scoped
187    /// [`content_hash`](Self::content_hash), never a canonical block hash, and
188    /// must not advance canonical coverage.
189    pub const fn block_ref(&self) -> BlockRef {
190        BlockRef {
191            number: self.block_number,
192            hash: self.content_hash,
193            parent_hash: self.parent_hash,
194            timestamp: self.timestamp,
195        }
196    }
197
198    /// Whether the cumulative preview contains `transaction_hash`.
199    pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
200        self.transaction_hashes.contains(transaction_hash)
201    }
202
203    fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
204        self.transaction_hashes
205            .iter()
206            .position(|candidate| candidate == transaction_hash)
207            .and_then(|index| u64::try_from(index).ok())
208    }
209
210    fn same_payload(&self, other: &Self) -> bool {
211        self.provider == other.provider
212            && match (self.payload_id, other.payload_id) {
213                (Some(left), Some(right)) => left == right,
214                _ => {
215                    self.block_number == other.block_number && self.parent_hash == other.parent_hash
216                }
217            }
218    }
219
220    #[cfg(feature = "raw-flashblocks-json")]
221    fn same_base_identity(&self, other: &Self) -> bool {
222        self.block_number == other.block_number
223            && self.parent_hash == other.parent_hash
224            && self.timestamp == other.timestamp
225            && self.base_fee_per_gas == other.base_fee_per_gas
226            && self.beneficiary == other.beneficiary
227            && self.prevrandao == other.prevrandao
228            && self.gas_limit == other.gas_limit
229    }
230
231    fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
232        self.same_payload(previous)
233            && self.transaction_hashes.len() >= previous.transaction_hashes.len()
234            && self
235                .transaction_hashes
236                .starts_with(&previous.transaction_hashes)
237            && match (previous.index, self.index) {
238                (Some(previous), Some(current)) => current >= previous,
239                _ => true,
240            }
241    }
242}
243
244/// Whether the subscriber may use Flashblocks for speculative delivery.
245#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
246pub enum PreconfirmationMode {
247    /// Use only canonical subscription/polling behavior.
248    #[default]
249    Disabled,
250    /// Prefer Flashblocks, but retain canonical operation when the selected
251    /// chain/provider cannot establish the pre-confirmation stream.
252    Preferred,
253    /// Fail setup/reconnect closed unless Flashblocks can be established.
254    Required,
255}
256
257/// Indexed OP Stack `newFlashblocks` subscription payload.
258#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
259pub struct BaseFlashblockPayload {
260    /// Block-builder payload id shared by every incremental snapshot.
261    pub payload_id: FixedBytes<8>,
262    /// Zero-based incremental snapshot index.
263    pub index: u64,
264    /// Header fields present on index zero.
265    pub base: Option<BaseFlashblockBase>,
266    /// Cumulative state commitments for this snapshot.
267    pub diff: BaseFlashblockDiff,
268    /// Supplemental block identity retained across current Base versions.
269    #[serde(default)]
270    pub metadata: Option<BaseFlashblockMetadata>,
271}
272
273/// Stable index-zero header subset from Base's Flashblocks wire format.
274#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
275pub struct BaseFlashblockBase {
276    /// Canonical parent block hash.
277    pub parent_hash: B256,
278    /// Pending block number.
279    #[serde(deserialize_with = "deserialize_rpc_u64")]
280    pub block_number: u64,
281    /// Pending block timestamp.
282    #[serde(deserialize_with = "deserialize_rpc_u64")]
283    pub timestamp: u64,
284    /// Pending block gas limit.
285    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
286    pub gas_limit: Option<u64>,
287    /// Pending EIP-1559 base fee.
288    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
289    pub base_fee_per_gas: Option<u64>,
290    /// Pending block beneficiary / fee recipient.
291    #[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
292    pub beneficiary: Option<Address>,
293    /// Pending block randomness value.
294    #[serde(
295        default,
296        alias = "prev_randao",
297        alias = "prevRandao",
298        alias = "mixHash"
299    )]
300    pub prevrandao: Option<B256>,
301}
302
303/// Stable commitment subset from Base's Flashblocks wire format.
304#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
305pub struct BaseFlashblockDiff {
306    /// State root after this cumulative snapshot.
307    pub state_root: B256,
308    /// Partial block hash after this cumulative snapshot.
309    pub block_hash: B256,
310    /// Transactions added by this indexed Flashblock diff.
311    #[serde(default)]
312    pub transactions: Vec<serde_json::Value>,
313    /// Transaction root when exposed by the provider.
314    #[serde(default)]
315    pub transactions_root: Option<B256>,
316}
317
318/// Stable metadata subset used when index-greater-than-zero payloads omit the
319/// Base header object.
320#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
321pub struct BaseFlashblockMetadata {
322    /// Pending block number (currently encoded as a JSON integer).
323    #[serde(deserialize_with = "deserialize_rpc_u64")]
324    pub block_number: u64,
325}
326
327/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack
328/// providers.
329#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
330#[serde(rename_all = "camelCase")]
331struct BaseFlashblockBlockPayload {
332    hash: B256,
333    #[serde(deserialize_with = "deserialize_rpc_u64")]
334    number: u64,
335    parent_hash: B256,
336    state_root: B256,
337    #[serde(default)]
338    transactions_root: Option<B256>,
339    #[serde(default)]
340    transactions: Vec<serde_json::Value>,
341    #[serde(deserialize_with = "deserialize_rpc_u64")]
342    timestamp: u64,
343    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
344    base_fee_per_gas: Option<u64>,
345    #[serde(default, alias = "beneficiary", alias = "feeRecipient")]
346    miner: Option<Address>,
347    #[serde(default, alias = "prevRandao")]
348    mix_hash: Option<B256>,
349    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
350    gas_limit: Option<u64>,
351}
352
353/// OP Stack providers expose either an indexed diff envelope or a cumulative
354/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout
355/// differences do not force callers onto separate subscriber paths.
356#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
357#[serde(untagged)]
358enum BaseFlashblockWirePayload {
359    Indexed(BaseFlashblockPayload),
360    Block(BaseFlashblockBlockPayload),
361}
362
363fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
364where
365    D: serde::Deserializer<'de>,
366{
367    #[derive(serde::Deserialize)]
368    #[serde(untagged)]
369    enum RpcU64 {
370        Number(u64),
371        String(String),
372    }
373
374    match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
375        RpcU64::Number(number) => Ok(number),
376        RpcU64::String(value) => {
377            let value = value.strip_prefix("0x").unwrap_or(&value);
378            u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
379        }
380    }
381}
382
383fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
384where
385    D: serde::Deserializer<'de>,
386{
387    #[derive(serde::Deserialize)]
388    #[serde(untagged)]
389    enum RpcU64 {
390        Number(u64),
391        String(String),
392    }
393
394    let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
395        return Ok(None);
396    };
397    match value {
398        RpcU64::Number(number) => Ok(Some(number)),
399        RpcU64::String(value) => {
400            let value = value.strip_prefix("0x").unwrap_or(&value);
401            u64::from_str_radix(value, 16)
402                .map(Some)
403                .map_err(serde::de::Error::custom)
404        }
405    }
406}
407
408fn non_placeholder_hash(hash: B256) -> Option<B256> {
409    (!hash.is_zero()).then_some(hash)
410}
411
412fn flashblock_transaction_hashes(
413    transactions: &[serde_json::Value],
414) -> Result<Vec<B256>, SubscriberError> {
415    let hashes: Vec<B256> = transactions
416        .iter()
417        .map(|transaction| {
418            let value = match transaction {
419                serde_json::Value::String(value) => value.as_str(),
420                serde_json::Value::Object(object) => object
421                    .get("hash")
422                    .or_else(|| object.get("transactionHash"))
423                    .and_then(serde_json::Value::as_str)
424                    .ok_or_else(|| {
425                        SubscriberError::Provider(
426                            "Flashblock transaction object is missing its hash".into(),
427                        )
428                    })?,
429                _ => {
430                    return Err(SubscriberError::Provider(
431                        "Flashblock transaction must be a hash, raw transaction, or object".into(),
432                    ));
433                }
434            };
435            if value.len() == 66 {
436                return value.parse::<B256>().map_err(|error| {
437                    SubscriberError::Provider(format!(
438                        "Flashblock transaction hash is invalid: {error}"
439                    ))
440                });
441            }
442            let encoded = value.strip_prefix("0x").unwrap_or(value);
443            let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
444                SubscriberError::Provider(format!(
445                    "Flashblock raw transaction is invalid hex: {error}"
446                ))
447            })?;
448            Ok(alloy_primitives::keccak256(raw))
449        })
450        .collect::<Result<_, _>>()?;
451    let mut unique = HashSet::with_capacity(hashes.len());
452    if hashes.iter().any(|hash| !unique.insert(*hash)) {
453        return Err(SubscriberError::Provider(
454            "Flashblock cumulative transaction membership contains a duplicate hash".into(),
455        ));
456    }
457    Ok(hashes)
458}
459
460struct FlashblockContentCommitment<'a> {
461    provider: &'a ProviderRef,
462    payload_id: Option<FixedBytes<8>>,
463    index: Option<u64>,
464    block_number: u64,
465    partial_block_hash: Option<B256>,
466    parent_hash: Option<B256>,
467    state_root: Option<B256>,
468    transactions_root: Option<B256>,
469    transaction_hashes: &'a [B256],
470    timestamp: Option<u64>,
471    base_fee_per_gas: Option<u64>,
472    beneficiary: Option<Address>,
473    prevrandao: Option<B256>,
474    gas_limit: Option<u64>,
475}
476
477fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
478    let mut commitment = Keccak256::new();
479    commitment.update(b"evm-fork-cache/flashblock-content/v1");
480    let endpoint = content.provider.endpoint.as_str().as_bytes();
481    commitment.update((endpoint.len() as u64).to_be_bytes());
482    commitment.update(endpoint);
483    commitment.update(content.provider.generation.to_be_bytes());
484    commitment.update(content.block_number.to_be_bytes());
485    commit_optional_bytes(
486        &mut commitment,
487        content.payload_id.as_ref().map(FixedBytes::as_slice),
488    );
489    commit_optional_u64(&mut commitment, content.index);
490    commit_optional_bytes(
491        &mut commitment,
492        content
493            .partial_block_hash
494            .as_ref()
495            .map(FixedBytes::as_slice),
496    );
497    commit_optional_bytes(
498        &mut commitment,
499        content.parent_hash.as_ref().map(FixedBytes::as_slice),
500    );
501    commit_optional_bytes(
502        &mut commitment,
503        content.state_root.as_ref().map(FixedBytes::as_slice),
504    );
505    commit_optional_bytes(
506        &mut commitment,
507        content.transactions_root.as_ref().map(FixedBytes::as_slice),
508    );
509    commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
510    for transaction_hash in content.transaction_hashes {
511        commitment.update(transaction_hash);
512    }
513    commit_optional_u64(&mut commitment, content.timestamp);
514    commit_optional_u64(&mut commitment, content.base_fee_per_gas);
515    commit_optional_bytes(
516        &mut commitment,
517        content
518            .beneficiary
519            .as_ref()
520            .map(|address| address.as_slice()),
521    );
522    commit_optional_bytes(
523        &mut commitment,
524        content.prevrandao.as_ref().map(FixedBytes::as_slice),
525    );
526    commit_optional_u64(&mut commitment, content.gas_limit);
527    let hash = commitment.finalize();
528    if hash.is_zero() {
529        B256::with_last_byte(1)
530    } else {
531        hash
532    }
533}
534
535#[cfg(feature = "raw-flashblocks-json")]
536fn validate_standard_flashblock_snapshot(
537    snapshot: &FlashblockSnapshot,
538) -> Result<(), SubscriberError> {
539    let flashblock = &snapshot.flashblock;
540    if flashblock.payload_id.is_none() || flashblock.index.is_none() {
541        return Err(SubscriberError::Provider(
542            "external Flashblock snapshot is missing its indexed payload identity".into(),
543        ));
544    }
545    let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment {
546        provider: &flashblock.provider,
547        payload_id: flashblock.payload_id,
548        index: flashblock.index,
549        block_number: flashblock.block_number,
550        partial_block_hash: flashblock.partial_block_hash,
551        parent_hash: flashblock.parent_hash,
552        state_root: flashblock.state_root,
553        transactions_root: flashblock.transactions_root,
554        transaction_hashes: &flashblock.transaction_hashes,
555        timestamp: flashblock.timestamp,
556        base_fee_per_gas: flashblock.base_fee_per_gas,
557        beneficiary: flashblock.beneficiary,
558        prevrandao: flashblock.prevrandao,
559        gas_limit: flashblock.gas_limit,
560    });
561    if flashblock.content_hash != expected_content_hash {
562        return Err(SubscriberError::Provider(
563            "external Flashblock content commitment is invalid".into(),
564        ));
565    }
566
567    let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len());
568    if flashblock
569        .transaction_hashes
570        .iter()
571        .any(|hash| !transactions.insert(*hash))
572    {
573        return Err(SubscriberError::Provider(
574            "external Flashblock cumulative transaction membership contains a duplicate hash"
575                .into(),
576        ));
577    }
578
579    let mut log_ids = HashSet::with_capacity(snapshot.logs.len());
580    for log in &snapshot.logs {
581        if log.removed || log.block_number != Some(flashblock.block_number) {
582            return Err(SubscriberError::Provider(
583                "external pre-confirmed log disagrees with its Flashblock block identity".into(),
584            ));
585        }
586        if log.block_hash != Some(flashblock.content_hash) {
587            return Err(SubscriberError::Provider(
588                "external pre-confirmed log is not bound to its Flashblock content commitment"
589                    .into(),
590            ));
591        }
592        let transaction_hash = log.transaction_hash.ok_or_else(|| {
593            SubscriberError::Provider(
594                "external pre-confirmed log is missing its transaction hash".into(),
595            )
596        })?;
597        let expected_transaction_index = flashblock
598            .transaction_index(&transaction_hash)
599            .ok_or_else(|| {
600                SubscriberError::Provider(
601                    "external pre-confirmed log transaction is absent from the cumulative Flashblock"
602                        .into(),
603                )
604            })?;
605        if log.transaction_index != Some(expected_transaction_index) {
606            return Err(SubscriberError::Provider(
607                "external pre-confirmed log transaction index disagrees with cumulative membership"
608                    .into(),
609            ));
610        }
611        let log_index = log.log_index.ok_or_else(|| {
612            SubscriberError::Provider("external pre-confirmed log is missing its log index".into())
613        })?;
614        if !log_ids.insert((transaction_hash, log_index)) {
615            return Err(SubscriberError::Provider(
616                "external Flashblock snapshot contains a duplicate log identity".into(),
617            ));
618        }
619    }
620    Ok(())
621}
622
623fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
624    match value {
625        Some(value) => {
626            commitment.update([1]);
627            commitment.update((value.len() as u64).to_be_bytes());
628            commitment.update(value);
629        }
630        None => commitment.update([0]),
631    }
632}
633
634fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
635    match value {
636        Some(value) => {
637            commitment.update([1]);
638            commitment.update(value.to_be_bytes());
639        }
640        None => commitment.update([0]),
641    }
642}
643
644/// Exact chain/block identity of an RPC cache snapshot adopted as the starting
645/// point for reactive event continuity.
646#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
647pub struct ReactiveCanonicalBaseline {
648    /// Chain whose state the cache snapshot contains.
649    pub chain_id: u64,
650    /// Canonical block through which the snapshot already embodies state.
651    pub block: BlockRef,
652}
653
654impl ReactiveCanonicalBaseline {
655    /// Construct an exact cache snapshot baseline.
656    pub const fn new(chain_id: u64, block: BlockRef) -> Self {
657        Self { chain_id, block }
658    }
659}
660
661/// Ordered chain-lifecycle control delivered by an event subscriber.
662///
663/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery
664/// token, durable checkpoint, and ordering guarantees as ordinary event data.
665/// Reorg controls are applied in declaration order before replacement records;
666/// progress, barrier, safe, and finalized controls are committed in declaration
667/// order after the records. A reorg declared after a post-record control is
668/// rejected because its ordering would otherwise be ambiguous.
669#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
670#[non_exhaustive]
671pub enum ChainControl {
672    /// Replace the old canonical branch after `common_ancestor` with `new_tip`.
673    Reorg {
674        /// Last block common to the old and new canonical branches.
675        common_ancestor: BlockRef,
676        /// Tip of the branch that ceased to be canonical.
677        old_tip: BlockRef,
678        /// Tip of the newly canonical branch known by the source.
679        new_tip: BlockRef,
680    },
681    /// Update the source's safe head.
682    Safe(BlockRef),
683    /// Update the source's finalized head.
684    Finalized(BlockRef),
685    /// Advance authoritative canonical coverage without fabricating a full header.
686    ///
687    /// Indexers that only know compact block identity should emit this control.
688    /// It never runs block handlers. The runtime exact-hash pins provider reads
689    /// and installs known `NUMBER`/timestamp values, but clears unproven
690    /// header-only environment fields such as base fee and beneficiary.
691    CanonicalProgress(BlockRef),
692    /// Ordered cutover or synchronization fence.
693    Barrier {
694        /// Subscriber-defined opaque barrier identity.
695        id: Vec<u8>,
696        /// Highest canonical event block included before the fence, if known.
697        block: Option<BlockRef>,
698    },
699}
700
701/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`].
702///
703/// Composite subscribers can persist this small chain-state view beside their
704/// own delivery checkpoint and validate a complete delivery envelope before it
705/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks
706/// without matching events need not be present), but it must contain at most
707/// one compatible identity per height. Its oldest entry is also the durable
708/// rollback horizon: an unretained explicit ancestor is accepted only when that
709/// oldest entry is at or below the ancestor. This type carries no cache data,
710/// event payloads, handler state, or transport-specific cursor.
711///
712/// The serde representation is a convenience for caller-owned persistence; it
713/// is not a versioned wire or checkpoint format. Durable protocols should wrap
714/// it in their own versioned envelope and define migrations before upgrading
715/// this pre-1.0 crate. External callers also own retention: successful
716/// validation appends canonical identities but does not silently discard the
717/// rollback proof window. Bound it with [`Self::retain_recent_history`] after
718/// committing the matching source cursor/ACK.
719#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
720pub struct CanonicalSequenceState {
721    retained_canonical_history: Vec<BlockRef>,
722    coverage_head: Option<BlockRef>,
723    safe_head: Option<BlockRef>,
724    finalized_head: Option<BlockRef>,
725}
726
727impl CanonicalSequenceState {
728    /// Construct a validation snapshot from retained canonical metadata.
729    ///
730    /// Construction does not validate ordering, adjacency, coverage, or
731    /// finality invariants. Call [`Self::validate`] before installing decoded or
732    /// externally assembled state.
733    pub fn new(
734        retained_canonical_history: Vec<BlockRef>,
735        coverage_head: Option<BlockRef>,
736        safe_head: Option<BlockRef>,
737        finalized_head: Option<BlockRef>,
738    ) -> Self {
739        Self {
740            retained_canonical_history,
741            coverage_head,
742            safe_head,
743            finalized_head,
744        }
745    }
746
747    /// Sparse retained canonical history in ascending processing order.
748    pub fn retained_canonical_history(&self) -> &[BlockRef] {
749        &self.retained_canonical_history
750    }
751
752    /// Highest canonical identity covered by this state, when known.
753    pub const fn coverage_head(&self) -> Option<&BlockRef> {
754        self.coverage_head.as_ref()
755    }
756
757    /// Latest safe head accepted by the validator, when known.
758    pub const fn safe_head(&self) -> Option<&BlockRef> {
759        self.safe_head.as_ref()
760    }
761
762    /// Latest finalized head accepted by the validator, when known.
763    pub const fn finalized_head(&self) -> Option<&BlockRef> {
764        self.finalized_head.as_ref()
765    }
766
767    /// Retain at most the newest `max_entries` canonical history identities.
768    ///
769    /// Coverage and safe/finalized heads are unchanged. The oldest retained
770    /// identity defines how far strict validation can prove a complete
771    /// rollback, so choose a bound at least as large as the deployment's
772    /// supported reorg depth and trim only after atomically committing the
773    /// corresponding validated state and source cursor. `0` intentionally
774    /// produces a coverage-only snapshot.
775    pub fn retain_recent_history(&mut self, max_entries: usize) {
776        let remove = self
777            .retained_canonical_history
778            .len()
779            .saturating_sub(max_entries);
780        self.retained_canonical_history.drain(..remove);
781    }
782
783    /// Validate a decoded/checkpointed snapshot before installing it.
784    ///
785    /// This rejects out-of-order or conflicting retained identities,
786    /// broken adjacent parent links, retained history without coverage,
787    /// incompatible coverage/finality aliases, hash reuse across heights,
788    /// known parent hashes at non-adjacent heights, finality beyond coverage,
789    /// and a finalized head beyond or conflicting with the safe head.
790    ///
791    /// # Errors
792    ///
793    /// Returns [`ReactiveError`] when any retained identity, parent link,
794    /// coverage alias, or safe/finalized relationship violates the canonical
795    /// snapshot invariants described above.
796    pub fn validate(&self) -> Result<(), ReactiveError> {
797        validate_canonical_sequence_snapshot(self)
798    }
799}
800
801/// Cache-free canonical transition proven by [`validate_canonical_sequence`].
802#[derive(Clone, Debug, PartialEq, Eq)]
803#[non_exhaustive]
804pub enum CanonicalSequenceMutation {
805    /// Rewind the listed retained identities and continue from `common_ancestor`.
806    Rewind {
807        /// Surviving canonical anchor, when one is retained or authenticated.
808        /// `None` is a transient same-envelope state: callers must stage the
809        /// complete validation atomically and may checkpoint only the returned
810        /// `next_state`, after a later canonical mutation installs the proven
811        /// replacement.
812        common_ancestor: Option<BlockRef>,
813        /// Exact retained identities removed by the transition.
814        dropped: Vec<BlockRef>,
815    },
816    /// Accept or enrich one canonical identity.
817    Canonical(BlockRef),
818    /// Accept a safe-head update with metadata resolved against prior state.
819    Safe(BlockRef),
820    /// Accept a finalized-head update with metadata resolved against prior state.
821    Finalized(BlockRef),
822}
823
824/// Successful result of provider-neutral canonical envelope validation.
825#[derive(Clone, Debug, PartialEq, Eq)]
826pub struct CanonicalSequenceValidation {
827    pre_record_state: CanonicalSequenceState,
828    next_state: CanonicalSequenceState,
829    mutations: Vec<CanonicalSequenceMutation>,
830    normalized_chain_controls: Vec<ChainControl>,
831}
832
833impl CanonicalSequenceValidation {
834    /// State after pre-record explicit reorg controls and before event records.
835    pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
836        &self.pre_record_state
837    }
838
839    /// Fully validated state after records and post-record controls.
840    pub const fn next_state(&self) -> &CanonicalSequenceState {
841        &self.next_state
842    }
843
844    /// Ordered cache-free canonical mutations proven by this envelope.
845    pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
846        &self.mutations
847    }
848
849    /// Controls safe to forward after composite overlap normalization.
850    ///
851    /// Ordinary validation retains the original controls. See
852    /// [`normalize_and_validate_canonical_sequence`] for the mode that removes
853    /// compatible stale progress and converts a stale blockful barrier into the
854    /// same barrier identity without a block assertion. Equal-height controls
855    /// that add previously absent parent/timestamp metadata remain present;
856    /// older compatible enrichment is intentionally not applied because the
857    /// corresponding regressive control is not forwarded to the runtime.
858    pub fn normalized_chain_controls(&self) -> &[ChainControl] {
859        &self.normalized_chain_controls
860    }
861}
862
863/// Lifecycle status for an input.
864#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
865#[non_exhaustive]
866pub enum ChainStatus {
867    /// The input is mempool-only and must not mutate canonical cache state.
868    Pending,
869    /// The input is ordered into an ephemeral sequencer-built Flashblock.
870    ///
871    /// Handlers may update the runtime's speculative overlay for this status,
872    /// but the update never advances canonical coverage or durable journals.
873    Preconfirmed {
874        /// Shared exact cumulative pre-confirmation snapshot observed by the
875        /// source. Sharing keeps ordinary canonical records compact and makes
876        /// multi-log Flashblock delivery cheap to clone.
877        flashblock: Arc<FlashblockRef>,
878    },
879    /// The input is included in a block with a confirmation count.
880    Included {
881        /// Included block.
882        block: BlockRef,
883        /// Confirmation count.
884        confirmations: u64,
885    },
886    /// The input is in the chain's safe head.
887    Safe {
888        /// Safe block.
889        block: BlockRef,
890    },
891    /// The input is in the finalized head.
892    Finalized {
893        /// Finalized block.
894        block: BlockRef,
895    },
896    /// The input was dropped by a reorg.
897    Reorged {
898        /// Block the input was dropped from.
899        dropped_from: BlockRef,
900    },
901}
902
903/// Source of an input batch.
904#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
905#[non_exhaustive]
906pub enum InputSource {
907    /// Caller-supplied batch.
908    Batch,
909    /// Live subscription stream.
910    Subscription,
911    /// Polling subscriber.
912    Poll,
913    /// Historical backfill.
914    Backfill,
915    /// Sequencer pre-confirmation / Flashblocks surface.
916    Flashblocks,
917    /// Test or synthetic input.
918    Synthetic,
919}
920
921/// Stable identity used for input deduplication and reports.
922#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
923pub enum InputRef {
924    /// Stable log identity.
925    Log {
926        /// Chain id, when known.
927        chain_id: Option<u64>,
928        /// Block hash containing the log.
929        block_hash: B256,
930        /// Transaction hash that emitted the log.
931        transaction_hash: B256,
932        /// Log index within the block.
933        log_index: u64,
934    },
935    /// Stable pending transaction identity.
936    PendingTx {
937        /// Chain id, when known.
938        chain_id: Option<u64>,
939        /// Transaction hash.
940        hash: B256,
941    },
942    /// Stable block identity.
943    Block {
944        /// Chain id, when known.
945        chain_id: Option<u64>,
946        /// Block hash.
947        hash: B256,
948        /// Block number.
949        number: u64,
950    },
951}
952
953/// Representation and lifecycle class retained alongside an [`InputRef`].
954///
955/// `InputRef` identifies the underlying chain object. This discriminator keeps
956/// distinct handler inputs from collapsing merely because they commit to the
957/// same object: a header and full block, a pending hash and hydrated body, and
958/// canonical versus reorg-signalling log delivery are independently routable.
959#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
960#[non_exhaustive]
961pub enum ReactiveInputKind {
962    /// Canonical log data.
963    CanonicalLog,
964    /// Removed or otherwise reorg-signalling log data.
965    ReorgSignalLog,
966    /// Header-only block representation.
967    BlockHeader,
968    /// Full block representation.
969    FullBlock,
970    /// Hash-only pending transaction representation.
971    PendingTxHash,
972    /// Hydrated pending transaction representation.
973    PendingTx,
974}
975
976/// Validated, representation-aware identity for one reactive input.
977///
978/// Composite subscribers can use this as a dedupe key without conflating
979/// independently routable representations. When a key repeats, use
980/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true
981/// provider overlap from a conflicting payload.
982#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
983pub struct ReactiveInputIdentity {
984    input_ref: InputRef,
985    kind: ReactiveInputKind,
986}
987
988impl ReactiveInputIdentity {
989    /// Validate and construct an identity from explicit wire/codec parts.
990    ///
991    /// `InputRef` identifies the underlying object, while `kind` identifies its
992    /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`],
993    /// block representations with [`InputRef::Block`], and pending-transaction
994    /// representations with [`InputRef::PendingTx`]. This constructor lets
995    /// external codecs rebuild the otherwise-private invariant without serde or
996    /// layout-dependent decoding.
997    ///
998    /// # Errors
999    ///
1000    /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong
1001    /// to the supplied representation `kind`.
1002    pub fn try_from_parts(
1003        input_ref: InputRef,
1004        kind: ReactiveInputKind,
1005    ) -> Result<Self, ReactiveInputIdentityError> {
1006        let compatible = matches!(
1007            (input_ref, kind),
1008            (
1009                InputRef::Log { .. },
1010                ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
1011            ) | (
1012                InputRef::Block { .. },
1013                ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
1014            ) | (
1015                InputRef::PendingTx { .. },
1016                ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
1017            )
1018        );
1019        if !compatible {
1020            return Err(ReactiveInputIdentityError { input_ref, kind });
1021        }
1022        Ok(Self { input_ref, kind })
1023    }
1024
1025    /// Underlying stable chain-object reference.
1026    pub const fn input_ref(&self) -> InputRef {
1027        self.input_ref
1028    }
1029
1030    /// Exact handler-input representation and lifecycle class.
1031    pub const fn kind(&self) -> ReactiveInputKind {
1032        self.kind
1033    }
1034}
1035
1036/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible
1037/// object/representation classes.
1038#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1039#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
1040pub struct ReactiveInputIdentityError {
1041    input_ref: InputRef,
1042    kind: ReactiveInputKind,
1043}
1044
1045impl ReactiveInputIdentityError {
1046    /// Rejected stable object reference.
1047    pub const fn input_ref(&self) -> InputRef {
1048        self.input_ref
1049    }
1050
1051    /// Rejected representation/lifecycle kind.
1052    pub const fn kind(&self) -> ReactiveInputKind {
1053        self.kind
1054    }
1055}
1056
1057/// Reliability of state effects emitted by a handler.
1058#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1059pub enum StateEffectQuality {
1060    /// Effects are exact from the input alone.
1061    ExactFromInput,
1062    /// Effects were applied, but follow-up resync is pending.
1063    AppliedWithPendingResync,
1064    /// Effects came from authoritative resync.
1065    ResyncedAuthoritatively,
1066    /// State requires repair before it should be trusted.
1067    RequiresRepair,
1068    /// No canonical state effect was emitted.
1069    NoStateEffect,
1070}
1071
1072/// Identifier for a reactive handler.
1073#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
1074pub struct HandlerId(String);
1075
1076impl HandlerId {
1077    /// Create a non-empty handler id.
1078    ///
1079    /// # Panics
1080    ///
1081    /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted
1082    /// configuration or wire input.
1083    pub fn new(id: impl Into<String>) -> Self {
1084        Self::try_new(id).expect("handler id must not be empty")
1085    }
1086
1087    /// Validate and create a handler id from untrusted input.
1088    ///
1089    /// # Errors
1090    ///
1091    /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is
1092    /// reserved for canonical/global protocol scope.
1093    pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
1094        let id = id.into();
1095        if id.is_empty() {
1096            return Err(HandlerIdError);
1097        }
1098        Ok(Self(id))
1099    }
1100
1101    /// Return the id as a string slice.
1102    pub fn as_str(&self) -> &str {
1103        &self.0
1104    }
1105}
1106
1107impl<'de> serde::Deserialize<'de> for HandlerId {
1108    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1109    where
1110        D: serde::Deserializer<'de>,
1111    {
1112        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
1113        Self::try_new(id).map_err(serde::de::Error::custom)
1114    }
1115}
1116
1117/// An empty handler identity cannot be represented portably across subscriber
1118/// protocols because the empty owner is reserved for canonical/global scope.
1119#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
1120#[error("handler id must not be empty")]
1121pub struct HandlerIdError;
1122
1123impl fmt::Display for HandlerId {
1124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1125        self.0.fmt(f)
1126    }
1127}
1128
1129/// Lightweight report label.
1130#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1131pub struct ReportTag {
1132    /// Label key.
1133    pub key: String,
1134    /// Label value.
1135    pub value: String,
1136}
1137
1138impl ReportTag {
1139    /// Create a report tag.
1140    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1141        Self {
1142            key: key.into(),
1143            value: value.into(),
1144        }
1145    }
1146}
1147
1148/// Domain-neutral hook signal emitted by a handler.
1149#[derive(Clone)]
1150pub struct HookSignal {
1151    /// Signal namespace owned by the caller.
1152    pub namespace: Cow<'static, str>,
1153    /// Signal kind within the namespace.
1154    pub kind: Cow<'static, str>,
1155    /// Additional labels for routing or observability.
1156    pub labels: Vec<ReportTag>,
1157    /// Optional in-process typed payload.
1158    pub payload: Option<Arc<dyn Any + Send + Sync>>,
1159}
1160
1161impl fmt::Debug for HookSignal {
1162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1163        f.debug_struct("HookSignal")
1164            .field("namespace", &self.namespace)
1165            .field("kind", &self.kind)
1166            .field("labels", &self.labels)
1167            .field("payload", &self.payload.as_ref().map(|_| "<payload>"))
1168            .finish()
1169    }
1170}
1171
1172/// Effect emitted by a [`ReactiveHandler`].
1173#[derive(Clone, Debug)]
1174pub enum ReactiveEffect {
1175    /// Canonical cache mutation applied through [`EvmCache::apply_updates`].
1176    StateUpdate(StateUpdate),
1177    /// Request for authoritative state repair.
1178    Resync(ResyncRequest),
1179    /// Rich invalidation request lowered to [`StateUpdate::Purge`].
1180    Invalidate(InvalidationRequest),
1181    /// Hook signal dispatched after committed mutation phases.
1182    Hook(HookSignal),
1183    /// Speculative signal for mempool or downstream work.
1184    Speculative(SpeculativeRequest),
1185}
1186
1187/// Handler output for a single input.
1188#[derive(Clone, Debug)]
1189pub struct HandlerOutcome {
1190    /// Effects emitted by the handler.
1191    pub effects: Vec<ReactiveEffect>,
1192    /// Reliability of emitted state effects.
1193    pub quality: StateEffectQuality,
1194    /// Labels copied into reports.
1195    pub tags: Vec<ReportTag>,
1196}
1197
1198impl HandlerOutcome {
1199    /// Construct an empty outcome with the supplied quality.
1200    pub fn empty(quality: StateEffectQuality) -> Self {
1201        Self {
1202            effects: Vec::new(),
1203            quality,
1204            tags: Vec::new(),
1205        }
1206    }
1207}
1208
1209/// One input and its execution context.
1210#[derive(Clone, Debug)]
1211pub struct ReactiveInputRecord<N: Network = Ethereum> {
1212    /// Input value.
1213    pub input: ReactiveInput<N>,
1214    /// Input context.
1215    pub context: ReactiveContext,
1216    /// Provider session that originated this input, when it came from a
1217    /// concrete provider rather than a synthetic or aggregate source.
1218    pub provider: Option<ProviderRef>,
1219}
1220
1221impl<N: Network> ReactiveInputRecord<N> {
1222    /// Create an input record.
1223    pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
1224        Self {
1225            input,
1226            context,
1227            provider: None,
1228        }
1229    }
1230
1231    /// Attach provider provenance used to route follow-up reads.
1232    #[must_use]
1233    pub fn with_provider(mut self, provider: ProviderRef) -> Self {
1234        self.provider = Some(provider);
1235        self
1236    }
1237
1238    /// Compute the stable input reference used for deduplication.
1239    pub fn input_ref(&self) -> InputRef {
1240        input_ref(&self.input, &self.context)
1241    }
1242
1243    /// Validate payload/context coherence and return a representation-aware
1244    /// identity suitable for subscriber and runtime deduplication.
1245    ///
1246    /// Validation is fail-closed for canonical logs: their block, transaction,
1247    /// and log positions must be complete and agree with the context. Block and
1248    /// pending-transaction representations receive the corresponding lifecycle,
1249    /// inclusion-wrapper, and payload/context checks. This does not recompute a
1250    /// claimed header hash, transaction root, or transaction signature; exact
1251    /// subscriber payload commitments remain the transport-integrity boundary
1252    /// for those cryptographic claims.
1253    ///
1254    /// # Errors
1255    ///
1256    /// Returns [`ReactiveError::InvalidInputRecord`] when the payload,
1257    /// lifecycle, inclusion metadata, or context is incomplete or internally
1258    /// inconsistent.
1259    pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
1260        validate_input_record(self)?;
1261        let kind = match &self.input {
1262            ReactiveInput::Log(log)
1263                if log.removed
1264                    || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
1265            {
1266                ReactiveInputKind::ReorgSignalLog
1267            }
1268            ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
1269            ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
1270            ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
1271            ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
1272            ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
1273        };
1274        ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
1275            ReactiveError::InvalidInputRecord {
1276                message: error.to_string(),
1277            }
1278        })
1279    }
1280
1281    /// Whether two same-identity records carry the same deduplicable payload.
1282    ///
1283    /// This deliberately ignores [`ReactiveContext`]: the same provider object
1284    /// can legitimately arrive from backfill and subscription transports with
1285    /// different provenance or confirmation metadata. Callers must first
1286    /// compare [`validated_identity`](Self::validated_identity) and reconcile
1287    /// lifecycle/context authority separately. Logs are compared structurally;
1288    /// block and transaction hashes are cryptographic commitments for the
1289    /// remaining same-representation payloads. Full block responses and
1290    /// hydrated pending transaction bodies deliberately return `false`: the
1291    /// core does not currently prove a supplied body against the header's
1292    /// transaction root or compare every response field, so a composite source
1293    /// must preserve both rather than suppress one based only on its hash.
1294    pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
1295        match (&self.input, &other.input) {
1296            (ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
1297                left.inner == right.inner
1298                    && left.block_hash == right.block_hash
1299                    && left.block_number == right.block_number
1300                    && optional_metadata_compatible(
1301                        left.block_timestamp.as_ref(),
1302                        right.block_timestamp.as_ref(),
1303                    )
1304                    && left.transaction_hash == right.transaction_hash
1305                    && left.transaction_index == right.transaction_index
1306                    && left.log_index == right.log_index
1307                    && left.removed == right.removed
1308            }
1309            (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
1310                left.hash() == right.hash()
1311            }
1312            (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
1313            (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
1314                left == right
1315            }
1316            (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
1317            _ => false,
1318        }
1319    }
1320
1321    /// Whether this representation has a complete payload-equivalence contract
1322    /// and may participate in duplicate suppression.
1323    ///
1324    /// Full block and hydrated pending transaction bodies are intentionally
1325    /// excluded until their complete body/response integrity is validated.
1326    pub fn is_payload_deduplicable(&self) -> bool {
1327        matches!(
1328            &self.input,
1329            ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
1330        )
1331    }
1332
1333    /// Merge `other` when it is the same safely deduplicable provider object.
1334    ///
1335    /// Returns `Ok(false)` for a different identity or a representation whose
1336    /// complete payload cannot be proven equivalent. A same-identity payload or
1337    /// semantic conflict returns an error. Successful merges are deterministic:
1338    /// optional block/timestamp metadata is enriched, canonical lifecycle moves
1339    /// toward `Finalized` then `Safe` then the highest-confirmation `Included`,
1340    /// and provenance uses a stable source priority. The result is therefore
1341    /// independent of historical/live arrival order.
1342    ///
1343    /// # Errors
1344    ///
1345    /// Returns [`ReactiveError`] when either record is invalid, or when equal
1346    /// identities carry conflicting payload or semantic context.
1347    pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
1348        let identity = self.validated_identity()?;
1349        let other_identity = other.validated_identity()?;
1350        if identity != other_identity
1351            || !self.is_payload_deduplicable()
1352            || !other.is_payload_deduplicable()
1353        {
1354            return Ok(false);
1355        }
1356        if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
1357            return Err(ReactiveError::InvalidInputRecord {
1358                message: format!(
1359                    "conflicting payload or semantic context for identity {identity:?}"
1360                ),
1361            });
1362        }
1363        let mut merged = self.clone();
1364        merge_deduplicable_record(&mut merged, other);
1365        merged.validated_identity()?;
1366        *self = merged;
1367        Ok(true)
1368    }
1369
1370    /// Whether semantic context agrees for deduplication across transports.
1371    ///
1372    /// Provenance source and confirmation count may legitimately differ at a
1373    /// historical/live overlap and are ignored. Chain id, lifecycle class, and
1374    /// transaction/log positions must agree. Block number/hash are exact;
1375    /// optional parent/timestamp metadata may be enriched by one source but two
1376    /// present conflicting values are rejected.
1377    pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
1378        let left = &self.context;
1379        let right = &other.context;
1380        left.chain_id == right.chain_id
1381            && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
1382            && left.transaction_index == right.transaction_index
1383            && left.log_index == right.log_index
1384            && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
1385    }
1386}
1387
1388fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
1389    match (left, right) {
1390        (ChainStatus::Pending, ChainStatus::Pending)
1391        | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
1392        (
1393            ChainStatus::Preconfirmed { flashblock: left },
1394            ChainStatus::Preconfirmed { flashblock: right },
1395        ) => left == right,
1396        (
1397            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1398            ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
1399        ) => true,
1400        _ => false,
1401    }
1402}
1403
1404fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
1405    left.zip(right).is_none_or(|(left, right)| left == right)
1406}
1407
1408fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
1409    match (left, right) {
1410        (None, None) => true,
1411        (Some(left), Some(right)) => {
1412            left.number == right.number
1413                && left.hash == right.hash
1414                && optional_metadata_compatible(
1415                    left.parent_hash.as_ref(),
1416                    right.parent_hash.as_ref(),
1417                )
1418                && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
1419        }
1420        _ => false,
1421    }
1422}
1423
1424fn merge_deduplicable_record<N: Network>(
1425    retained: &mut ReactiveInputRecord<N>,
1426    incoming: &ReactiveInputRecord<N>,
1427) {
1428    if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
1429        (&mut retained.input, &incoming.input)
1430        && retained.block_timestamp.is_none()
1431    {
1432        retained.block_timestamp = incoming.block_timestamp;
1433    }
1434    if let (Some(retained), Some(incoming)) =
1435        (&mut retained.context.block, incoming.context.block.as_ref())
1436    {
1437        enrich_block_ref(retained, incoming);
1438    }
1439    retained.context.chain_status = merged_chain_status(
1440        &retained.context.chain_status,
1441        &incoming.context.chain_status,
1442    );
1443    if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
1444        retained.context.source = incoming.context.source;
1445    }
1446    if retained.provider.is_none() {
1447        retained.provider = incoming.provider.clone();
1448    }
1449}
1450
1451fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
1452    if retained.parent_hash.is_none() {
1453        retained.parent_hash = incoming.parent_hash;
1454    }
1455    if retained.timestamp.is_none() {
1456        retained.timestamp = incoming.timestamp;
1457    }
1458}
1459
1460fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
1461    let merged_block = |left: &BlockRef, right: &BlockRef| {
1462        let mut block = *left;
1463        enrich_block_ref(&mut block, right);
1464        block
1465    };
1466    match (retained, incoming) {
1467        (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
1468        (
1469            ChainStatus::Preconfirmed { flashblock: left },
1470            ChainStatus::Preconfirmed { flashblock: right },
1471        ) => {
1472            debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
1473            ChainStatus::Preconfirmed {
1474                flashblock: left.clone(),
1475            }
1476        }
1477        (
1478            ChainStatus::Reorged { dropped_from: left },
1479            ChainStatus::Reorged {
1480                dropped_from: right,
1481            },
1482        ) => ChainStatus::Reorged {
1483            dropped_from: merged_block(left, right),
1484        },
1485        (left, right) => {
1486            let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
1487                .expect("compatible duplicate has a canonical lifecycle");
1488            let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
1489                .expect("compatible duplicate has a canonical lifecycle");
1490            let block = merged_block(left_block, right_block);
1491            let rank = left_rank.max(right_rank);
1492            match rank {
1493                3 => ChainStatus::Finalized { block },
1494                2 => ChainStatus::Safe { block },
1495                _ => ChainStatus::Included {
1496                    block,
1497                    confirmations: left_confirmations.max(right_confirmations),
1498                },
1499            }
1500        }
1501    }
1502}
1503
1504fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
1505    match status {
1506        ChainStatus::Included {
1507            block,
1508            confirmations,
1509        } => Some((block, 1, *confirmations)),
1510        ChainStatus::Safe { block } => Some((block, 2, 0)),
1511        ChainStatus::Finalized { block } => Some((block, 3, 0)),
1512        ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
1513            None
1514        }
1515    }
1516}
1517
1518fn input_source_rank(source: InputSource) -> u8 {
1519    match source {
1520        InputSource::Backfill => 0,
1521        InputSource::Poll => 1,
1522        InputSource::Subscription => 2,
1523        InputSource::Flashblocks => 3,
1524        InputSource::Batch => 4,
1525        InputSource::Synthetic => 5,
1526    }
1527}
1528
1529/// Opaque subscriber-owned token attached to a delivered input batch.
1530///
1531/// Subscribers that provide durable, at-least-once delivery can use this token
1532/// to identify the batch that becomes committable after runtime ingestion
1533/// succeeds. The runtime never interprets the bytes. A token must be immutable,
1534/// stable across replay, and must never identify two different batch payloads.
1535/// Subscriber implementations must preserve delivery order while one token is
1536/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a
1537/// later batch.
1538#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1539pub struct SubscriberDeliveryToken(Vec<u8>);
1540
1541impl SubscriberDeliveryToken {
1542    /// Create an opaque delivery token from subscriber-owned bytes.
1543    pub fn new(bytes: Vec<u8>) -> Self {
1544        Self(bytes)
1545    }
1546
1547    /// Borrow the opaque token bytes.
1548    pub fn as_bytes(&self) -> &[u8] {
1549        &self.0
1550    }
1551
1552    /// Consume the token into its opaque bytes.
1553    pub fn into_bytes(self) -> Vec<u8> {
1554        self.0
1555    }
1556}
1557
1558/// Opaque source checkpoint associated with a delivered batch.
1559///
1560/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to
1561/// acknowledge, this value describes provider-specific resume state. The core
1562/// crate persists and returns the bytes without interpreting their format.
1563#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1564pub struct SubscriberCheckpoint(Vec<u8>);
1565
1566impl SubscriberCheckpoint {
1567    /// Create an opaque source checkpoint from subscriber-owned bytes.
1568    pub fn new(bytes: Vec<u8>) -> Self {
1569        Self(bytes)
1570    }
1571
1572    /// Borrow the opaque checkpoint bytes.
1573    pub fn as_bytes(&self) -> &[u8] {
1574        &self.0
1575    }
1576
1577    /// Consume the checkpoint into its opaque bytes.
1578    pub fn into_bytes(self) -> Vec<u8> {
1579        self.0
1580    }
1581}
1582
1583/// Subscriber-supplied commitment to the exact canonical wire payload of one
1584/// delivered batch.
1585///
1586/// The core includes this value in its durable replay witness. It is required
1587/// for tokened block-header, full-block, and hydrated-transaction payloads whose
1588/// network-generic Rust response types cannot be serialized completely by the
1589/// core. The source must recompute the commitment from a stable canonical
1590/// encoding on every replay; reusing a commitment for changed bytes violates the
1591/// [`EventSubscriber`] contract.
1592#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1593pub struct SubscriberPayloadCommitment(B256);
1594
1595impl SubscriberPayloadCommitment {
1596    /// Wrap a cryptographic commitment produced by the subscriber.
1597    pub const fn new(commitment: B256) -> Self {
1598        Self(commitment)
1599    }
1600
1601    /// Return the committed digest.
1602    pub const fn digest(&self) -> B256 {
1603        self.0
1604    }
1605}
1606
1607/// Durable subscriber position restored together with cache/runtime state.
1608///
1609/// The core never interprets provider checkpoint bytes. Composite and remote
1610/// subscribers use this synchronous hand-off to seed their source cursors,
1611/// replay fences, and canonical overlap journals before polling resumes.
1612#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1613#[non_exhaustive]
1614pub struct SubscriberResumePosition {
1615    /// Chain whose canonical position and provider cursor are being restored.
1616    pub chain_id: u64,
1617    /// Authoritative canonical coverage embodied by the restored cache.
1618    pub coverage_head: BlockRef,
1619    /// Ordered canonical identities still retained for in-window reconciliation.
1620    pub canonical_history: Vec<BlockRef>,
1621    /// Last delivery token whose effects are already represented by the cache.
1622    /// It may still be pending at the source when the process stopped after its
1623    /// durable save but before the source acknowledgement committed.
1624    pub delivery_token: Option<SubscriberDeliveryToken>,
1625    /// Provider-specific durable cursor committed with that delivery.
1626    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1627}
1628
1629impl SubscriberResumePosition {
1630    /// Construct a complete restored subscriber position.
1631    pub fn new(
1632        chain_id: u64,
1633        coverage_head: BlockRef,
1634        canonical_history: Vec<BlockRef>,
1635        delivery_token: Option<SubscriberDeliveryToken>,
1636        subscriber_checkpoint: Option<SubscriberCheckpoint>,
1637    ) -> Self {
1638        Self {
1639            chain_id,
1640            coverage_head,
1641            canonical_history,
1642            delivery_token,
1643            subscriber_checkpoint,
1644        }
1645    }
1646}
1647
1648/// Runtime routing audience for one delivered subscriber batch.
1649///
1650/// Historical catch-up for a newly registered handler must not be routed
1651/// through older handlers whose filters happen to overlap. Subscribers retain
1652/// that provenance by targeting the batch at the exact logical owners that
1653/// requested it. Ordinary canonical delivery remains broadcast to every
1654/// matching handler.
1655#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1656#[non_exhaustive]
1657pub enum DeliveryAudience {
1658    /// Route each record through every matching registered handler.
1659    #[default]
1660    All,
1661    /// Route each record only through the named matching handlers.
1662    Owners(Vec<HandlerId>),
1663    /// Route through every matching handler except the named owners.
1664    ///
1665    /// Composite subscribers use this to deliver the residual audience after an
1666    /// overlapping source already committed the same input for selected owners.
1667    AllExcept(Vec<HandlerId>),
1668}
1669
1670/// How one delivered record participates in the runtime's canonical state machine.
1671///
1672/// Routing and chain authority are deliberately independent: [`DeliveryAudience`]
1673/// selects handlers, while this value decides whether a record may advance or
1674/// rewind global chain state. Historical replay for a newly added owner must use
1675/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status
1676/// is canonical.
1677#[derive(
1678    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1679)]
1680#[non_exhaustive]
1681pub enum DeliveryScope {
1682    /// Authoritative live canonical delivery.
1683    #[default]
1684    Canonical,
1685    /// Authoritative historical/recovery delivery that advances canonical progress.
1686    CanonicalProgress,
1687    /// Historical replay routed to selected owners without changing global chain state.
1688    OwnerCatchup,
1689    /// Ephemeral pre-confirmation delivery applied only to the speculative
1690    /// cache overlay.
1691    Preconfirmed,
1692}
1693
1694impl DeliveryScope {
1695    const fn advances_canonical_state(self) -> bool {
1696        matches!(self, Self::Canonical | Self::CanonicalProgress)
1697    }
1698}
1699
1700/// One input together with its routing and canonical-processing provenance.
1701#[derive(Clone, Debug)]
1702pub struct ReactiveInputDelivery<N: Network = Ethereum> {
1703    record: ReactiveInputRecord<N>,
1704    audience: DeliveryAudience,
1705    scope: DeliveryScope,
1706}
1707
1708impl<N: Network> ReactiveInputDelivery<N> {
1709    /// Construct one lossless delivered record.
1710    pub fn new(
1711        record: ReactiveInputRecord<N>,
1712        audience: DeliveryAudience,
1713        scope: DeliveryScope,
1714    ) -> Self {
1715        Self {
1716            record,
1717            audience,
1718            scope,
1719        }
1720    }
1721
1722    /// Borrow the runtime input record.
1723    pub const fn record(&self) -> &ReactiveInputRecord<N> {
1724        &self.record
1725    }
1726
1727    /// Borrow the exact routing audience.
1728    pub const fn audience(&self) -> &DeliveryAudience {
1729        &self.audience
1730    }
1731
1732    /// Return the record's canonical-processing scope.
1733    pub const fn scope(&self) -> DeliveryScope {
1734        self.scope
1735    }
1736
1737    /// Consume this value into its complete parts.
1738    pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
1739        (self.record, self.audience, self.scope)
1740    }
1741}
1742
1743/// Complete contents of a consumed [`ReactiveInputBatch`].
1744///
1745/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally
1746/// discards subscriber commit and chain-lifecycle metadata.
1747#[derive(Clone, Debug)]
1748#[non_exhaustive]
1749pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
1750    /// Authoritative chain identity for controls and records in this batch.
1751    pub chain_id: Option<u64>,
1752    /// Records with per-record routing and chain provenance.
1753    pub deliveries: Vec<ReactiveInputDelivery<N>>,
1754    /// Subscriber delivery token committed after ingestion.
1755    pub delivery_token: Option<SubscriberDeliveryToken>,
1756    /// Provider-specific resume cursor associated with the delivery.
1757    pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
1758    /// Exact opaque wire-payload commitment supplied by the subscriber.
1759    pub payload_commitment: Option<SubscriberPayloadCommitment>,
1760    /// Ordered chain controls sharing the delivery's commit boundary.
1761    pub chain_controls: Vec<ChainControl>,
1762}
1763
1764/// Batch of reactive input records.
1765#[derive(Clone, Debug)]
1766pub struct ReactiveInputBatch<N: Network = Ethereum> {
1767    records: Vec<ReactiveInputRecord<N>>,
1768    chain_id: Option<u64>,
1769    delivery_token: Option<SubscriberDeliveryToken>,
1770    subscriber_checkpoint: Option<SubscriberCheckpoint>,
1771    payload_commitment: Option<SubscriberPayloadCommitment>,
1772    audience: DeliveryAudience,
1773    record_audiences: Option<Vec<DeliveryAudience>>,
1774    delivery_scope: DeliveryScope,
1775    record_delivery_scopes: Option<Vec<DeliveryScope>>,
1776    chain_controls: Vec<ChainControl>,
1777}
1778
1779type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
1780
1781impl<N: Network> ReactiveInputBatch<N> {
1782    /// Create a batch from records.
1783    pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
1784        let chain_id = common_record_chain_id(&records);
1785        Self {
1786            records,
1787            chain_id,
1788            delivery_token: None,
1789            subscriber_checkpoint: None,
1790            payload_commitment: None,
1791            audience: DeliveryAudience::All,
1792            record_audiences: None,
1793            delivery_scope: DeliveryScope::Canonical,
1794            record_delivery_scopes: None,
1795            chain_controls: Vec::new(),
1796        }
1797    }
1798
1799    /// Bind the complete batch, including control-only progress/finality, to a
1800    /// chain. Runtime ingestion rejects a different cache chain.
1801    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
1802        self.chain_id = Some(chain_id);
1803        self
1804    }
1805
1806    /// Authoritative batch chain identity, when supplied or unambiguously
1807    /// derived from its records.
1808    pub const fn chain_id(&self) -> Option<u64> {
1809        self.chain_id
1810    }
1811
1812    /// Attach the subscriber-owned token committed after successful ingestion.
1813    pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
1814        self.delivery_token = Some(token);
1815        self
1816    }
1817
1818    /// Borrow the subscriber-owned delivery token, when present.
1819    pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
1820        self.delivery_token.as_ref()
1821    }
1822
1823    /// Attach provider-specific resume state included by this delivery.
1824    pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
1825        self.subscriber_checkpoint = Some(checkpoint);
1826        self
1827    }
1828
1829    /// Borrow provider-specific resume state, when present.
1830    pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
1831        self.subscriber_checkpoint.as_ref()
1832    }
1833
1834    /// Attach a commitment to the exact canonical wire payload represented by
1835    /// this batch.
1836    pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
1837        self.payload_commitment = Some(commitment);
1838        self
1839    }
1840
1841    /// Borrow the subscriber-supplied exact payload commitment, when present.
1842    pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
1843        self.payload_commitment.as_ref()
1844    }
1845
1846    /// Restrict runtime routing to exact logical interest owners.
1847    pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
1848        self.audience = audience;
1849        self.record_audiences = None;
1850        self
1851    }
1852
1853    /// Delivery audience captured by the subscriber.
1854    pub const fn audience(&self) -> &DeliveryAudience {
1855        &self.audience
1856    }
1857
1858    /// Create a batch whose records retain independent delivery audiences.
1859    pub fn from_scoped_records(
1860        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
1861    ) -> Self {
1862        let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
1863        let chain_id = common_record_chain_id(&records);
1864        Self {
1865            records,
1866            chain_id,
1867            delivery_token: None,
1868            subscriber_checkpoint: None,
1869            payload_commitment: None,
1870            audience: DeliveryAudience::All,
1871            record_audiences: Some(record_audiences),
1872            delivery_scope: DeliveryScope::Canonical,
1873            record_delivery_scopes: None,
1874            chain_controls: Vec::new(),
1875        }
1876    }
1877
1878    /// Create a batch with independent routing and canonical provenance per record.
1879    pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
1880        Self::from_scoped_records_with_delivery_scope(
1881            deliveries
1882                .into_iter()
1883                .map(ReactiveInputDelivery::into_parts),
1884        )
1885    }
1886
1887    /// Audience for the record at `index`.
1888    pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
1889        if index >= self.records.len() {
1890            return None;
1891        }
1892        Some(
1893            self.record_audiences
1894                .as_ref()
1895                .and_then(|audiences| audiences.get(index))
1896                .unwrap_or(&self.audience),
1897        )
1898    }
1899
1900    /// Set how every record in this batch participates in canonical state.
1901    pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
1902        self.delivery_scope = scope;
1903        self.record_delivery_scopes = None;
1904        self
1905    }
1906
1907    /// Canonical-processing scope for the record at `index`.
1908    pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
1909        if index >= self.records.len() {
1910            return None;
1911        }
1912        Some(
1913            self.record_delivery_scopes
1914                .as_ref()
1915                .and_then(|scopes| scopes.get(index))
1916                .copied()
1917                .unwrap_or(self.delivery_scope),
1918        )
1919    }
1920
1921    fn from_scoped_records_with_delivery_scope(
1922        records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
1923    ) -> Self {
1924        let mut input_records = Vec::new();
1925        let mut audiences = Vec::new();
1926        let mut scopes = Vec::new();
1927        for (record, audience, scope) in records {
1928            input_records.push(record);
1929            audiences.push(audience);
1930            scopes.push(scope);
1931        }
1932        let chain_id = common_record_chain_id(&input_records);
1933        Self {
1934            records: input_records,
1935            chain_id,
1936            delivery_token: None,
1937            subscriber_checkpoint: None,
1938            payload_commitment: None,
1939            audience: DeliveryAudience::All,
1940            record_audiences: Some(audiences),
1941            delivery_scope: DeliveryScope::Canonical,
1942            record_delivery_scopes: Some(scopes),
1943            chain_controls: Vec::new(),
1944        }
1945    }
1946
1947    /// Attach ordered chain-lifecycle controls to this delivery.
1948    ///
1949    /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id).
1950    /// When records are present, their unanimous chain id is derived by the
1951    /// constructor; a missing or cache-mismatched authoritative batch identity
1952    /// is rejected before any control mutates runtime state.
1953    pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
1954        self.chain_controls = controls.into_iter().collect();
1955        self
1956    }
1957
1958    /// Ordered chain-lifecycle controls in this delivery.
1959    pub fn chain_controls(&self) -> &[ChainControl] {
1960        &self.chain_controls
1961    }
1962
1963    /// Borrow the records in this batch.
1964    pub fn records(&self) -> &[ReactiveInputRecord<N>] {
1965        &self.records
1966    }
1967
1968    /// Consume the batch into only its input records.
1969    ///
1970    /// This is intentionally lossy: it discards the authoritative batch chain
1971    /// identity, routing audiences, delivery scopes, ordered chain controls,
1972    /// acknowledgement tokens, and provider checkpoints. Adapters should use
1973    /// [`into_parts`](Self::into_parts) instead.
1974    pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
1975        self.records
1976    }
1977
1978    /// Consume the batch without losing subscriber or chain-lifecycle metadata.
1979    pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
1980        let chain_id = self.chain_id;
1981        let delivery_token = self.delivery_token;
1982        let subscriber_checkpoint = self.subscriber_checkpoint;
1983        let payload_commitment = self.payload_commitment;
1984        let chain_controls = self.chain_controls;
1985        let audiences = self
1986            .record_audiences
1987            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
1988        let scopes = self
1989            .record_delivery_scopes
1990            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
1991        let deliveries = self
1992            .records
1993            .into_iter()
1994            .zip(audiences)
1995            .zip(scopes)
1996            .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
1997            .collect();
1998        ReactiveInputBatchParts {
1999            chain_id,
2000            deliveries,
2001            delivery_token,
2002            subscriber_checkpoint,
2003            payload_commitment,
2004            chain_controls,
2005        }
2006    }
2007
2008    fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
2009        let audiences = self
2010            .record_audiences
2011            .unwrap_or_else(|| vec![self.audience; self.records.len()]);
2012        let scopes = self
2013            .record_delivery_scopes
2014            .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
2015        let records = self
2016            .records
2017            .into_iter()
2018            .zip(audiences)
2019            .zip(scopes)
2020            .map(|((record, audience), scope)| (record, audience, scope))
2021            .collect();
2022        (records, self.chain_controls, self.chain_id)
2023    }
2024
2025    fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
2026        self.delivery_token.take()
2027    }
2028
2029    fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
2030        self.subscriber_checkpoint.take()
2031    }
2032}
2033
2034fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
2035    let chain_id = records.first()?.context.chain_id?;
2036    records
2037        .iter()
2038        .all(|record| record.context.chain_id == Some(chain_id))
2039        .then_some(chain_id)
2040}
2041
2042/// Pure synchronous handler for reactive inputs.
2043pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
2044    /// Stable handler id.
2045    fn id(&self) -> HandlerId;
2046
2047    /// Interests used by subscribers and the local router.
2048    fn interests(&self) -> Vec<ReactiveInterest<N>>;
2049
2050    /// Exhaustive exact keys for log inputs this handler can accept.
2051    ///
2052    /// Returning `None` keeps the handler on the compatibility fallback path.
2053    /// Returning an index promises that every matching log has at least one of
2054    /// its keys; the registry still re-checks the handler's original
2055    /// [`LogInterest`]s and local matchers before dispatch.
2056    fn log_route_index(&self) -> Option<LogRouteIndex> {
2057        None
2058    }
2059
2060    /// Handle one input against a read-only cache view.
2061    fn handle(
2062        &self,
2063        ctx: &ReactiveContext,
2064        input: &ReactiveInput<N>,
2065        state: &dyn StateView,
2066    ) -> Result<HandlerOutcome, HandlerError>;
2067}
2068
2069/// Hook invoked after reports are built and cache mutation phases have ended.
2070///
2071/// Hooks are synchronous in-process observers, not a durable transactional
2072/// outbox. The runtime never dispatches reports for a batch it rejects or rolls
2073/// back during checkpoint staging, and it dispatches a successfully staged
2074/// batch at most once per live engine. A process crash can still occur between
2075/// hook dispatch and durable checkpoint or transport acknowledgement. External
2076/// side effects therefore need their own idempotency key (normally an
2077/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism.
2078pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
2079    /// Observe a runtime report.
2080    fn on_report(&self, report: Arc<ReactiveReport<N>>);
2081}
2082
2083/// Reactive subscription interest.
2084#[allow(clippy::large_enum_variant)]
2085#[derive(Clone)]
2086pub enum ReactiveInterest<N: Network = Ethereum> {
2087    /// Log interest.
2088    Logs(LogInterest),
2089    /// Block interest.
2090    Blocks(BlockInterest),
2091    /// Pending transaction interest.
2092    PendingTransactions(PendingTxInterest<N>),
2093}
2094
2095impl<N: Network> fmt::Debug for ReactiveInterest<N> {
2096    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2097        match self {
2098            Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
2099            Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
2100            Self::PendingTransactions(interest) => f
2101                .debug_tuple("PendingTransactions")
2102                .field(interest)
2103                .finish(),
2104        }
2105    }
2106}
2107
2108/// Interest in logs.
2109#[derive(Clone)]
2110pub struct LogInterest {
2111    /// Provider-side filter.
2112    pub provider_filter: Filter,
2113    /// Optional local matcher for predicates providers cannot express.
2114    pub local_matcher: Option<Arc<dyn LogMatcher>>,
2115    /// Optional route-key extraction strategy.
2116    pub route_key: Option<RouteKeySpec>,
2117}
2118
2119impl LogInterest {
2120    /// Return true if the log matches both the provider filter and local matcher.
2121    pub fn matches(&self, log: &Log) -> bool {
2122        self.provider_filter.rpc_matches(log)
2123            && self
2124                .local_matcher
2125                .as_ref()
2126                .is_none_or(|matcher| matcher.matches(log))
2127    }
2128
2129    /// Extract the route key for a matching log, if configured.
2130    pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
2131        self.route_key.as_ref().and_then(|spec| spec.extract(log))
2132    }
2133}
2134
2135impl fmt::Debug for LogInterest {
2136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2137        f.debug_struct("LogInterest")
2138            .field("provider_filter", &self.provider_filter)
2139            .field(
2140                "local_matcher",
2141                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2142            )
2143            .field("route_key", &self.route_key)
2144            .finish()
2145    }
2146}
2147
2148/// Local log predicate.
2149pub trait LogMatcher: Send + Sync {
2150    /// Return true when the log should be routed to the handler.
2151    fn matches(&self, log: &Log) -> bool;
2152}
2153
2154/// Route-key extraction strategy for logs.
2155#[derive(Clone)]
2156pub enum RouteKeySpec {
2157    /// Route by emitting address.
2158    EmitterAddress,
2159    /// Route by indexed topic.
2160    Topic {
2161        /// Topic index.
2162        index: usize,
2163    },
2164    /// Route by a byte slice in log data.
2165    DataSlice {
2166        /// Byte offset in the data payload.
2167        offset: usize,
2168        /// Number of bytes to copy.
2169        len: usize,
2170    },
2171    /// Custom extractor.
2172    Custom(Arc<dyn RouteKeyExtractor>),
2173}
2174
2175impl RouteKeySpec {
2176    /// Extract a route key from a log.
2177    pub fn extract(&self, log: &Log) -> Option<RouteKey> {
2178        match self {
2179            Self::EmitterAddress => Some(RouteKey::Address(log.address())),
2180            Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
2181            Self::DataSlice { offset, len } => {
2182                let data = log.inner.data.data.as_ref();
2183                let end = offset.checked_add(*len)?;
2184                data.get(*offset..end)
2185                    .map(|bytes| RouteKey::Bytes(bytes.to_vec()))
2186            }
2187            Self::Custom(extractor) => extractor.extract(log),
2188        }
2189    }
2190}
2191
2192impl fmt::Debug for RouteKeySpec {
2193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2194        match self {
2195            Self::EmitterAddress => f.write_str("EmitterAddress"),
2196            Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
2197            Self::DataSlice { offset, len } => f
2198                .debug_struct("DataSlice")
2199                .field("offset", offset)
2200                .field("len", len)
2201                .finish(),
2202            Self::Custom(_) => f.write_str("Custom(<extractor>)"),
2203        }
2204    }
2205}
2206
2207/// Extracts custom route keys from logs.
2208pub trait RouteKeyExtractor: Send + Sync {
2209    /// Extract a route key.
2210    fn extract(&self, log: &Log) -> Option<RouteKey>;
2211}
2212
2213/// Extracted route key.
2214#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2215pub enum RouteKey {
2216    /// Address key.
2217    Address(Address),
2218    /// 32-byte key.
2219    Bytes32(B256),
2220    /// Arbitrary bytes key.
2221    Bytes(Vec<u8>),
2222}
2223
2224/// Exact protocol-neutral key used to select candidate log handlers.
2225#[non_exhaustive]
2226#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2227pub enum LogRouteKey {
2228    /// Emitting contract address.
2229    Emitter(Address),
2230    /// Exact indexed topic.
2231    Topic {
2232        /// Topic position in the log.
2233        index: usize,
2234        /// Expected topic value.
2235        value: B256,
2236    },
2237    /// Exact byte slice in the log data.
2238    DataSlice {
2239        /// Byte offset in the data payload.
2240        offset: usize,
2241        /// Expected bytes.
2242        value: Vec<u8>,
2243    },
2244}
2245
2246/// Non-empty exhaustive OR-set of exact log route keys.
2247#[derive(Clone, Debug, PartialEq, Eq)]
2248pub struct LogRouteIndex {
2249    keys: Vec<LogRouteKey>,
2250}
2251
2252impl LogRouteIndex {
2253    /// Construct an index from one required key and optional additional keys.
2254    pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
2255        let mut keys = vec![primary];
2256        for key in additional {
2257            if !keys.contains(&key) {
2258                keys.push(key);
2259            }
2260        }
2261        Self { keys }
2262    }
2263
2264    /// Construct a single-key index.
2265    pub fn single(key: LogRouteKey) -> Self {
2266        Self { keys: vec![key] }
2267    }
2268
2269    /// Exact keys in declaration order.
2270    pub fn keys(&self) -> &[LogRouteKey] {
2271        &self.keys
2272    }
2273}
2274
2275/// Exact log route selected by [`ReactiveRegistry::route_log`].
2276#[derive(Clone, Debug, PartialEq, Eq)]
2277pub struct ReactiveLogRoute {
2278    /// Handler whose log interest matched.
2279    pub handler_id: HandlerId,
2280    /// Optional route key extracted from the matching log interest.
2281    pub route_key: Option<RouteKey>,
2282}
2283
2284/// Interest in block inputs.
2285#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2286pub struct BlockInterest {
2287    /// Block input mode.
2288    pub mode: BlockInterestMode,
2289}
2290
2291impl Default for BlockInterest {
2292    fn default() -> Self {
2293        Self {
2294            mode: BlockInterestMode::Header,
2295        }
2296    }
2297}
2298
2299/// Block subscription mode.
2300#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2301pub enum BlockInterestMode {
2302    /// Header-only block input.
2303    Header,
2304    /// Full block input.
2305    FullBlock,
2306}
2307
2308/// Interest in pending transaction inputs.
2309#[derive(Clone)]
2310pub struct PendingTxInterest<N: Network = Ethereum> {
2311    /// Whether the handler requires full transaction bodies.
2312    pub full_transactions: bool,
2313    /// Sender matcher.
2314    pub from: AddressMatcher,
2315    /// Recipient matcher.
2316    pub to: AddressMatcher,
2317    /// Calldata selector matcher.
2318    pub selectors: SelectorMatcher,
2319    /// Optional local transaction matcher.
2320    pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
2321}
2322
2323impl<N: Network> Default for PendingTxInterest<N> {
2324    fn default() -> Self {
2325        Self {
2326            full_transactions: false,
2327            from: AddressMatcher::Any,
2328            to: AddressMatcher::Any,
2329            selectors: SelectorMatcher::Any,
2330            local_matcher: None,
2331        }
2332    }
2333}
2334
2335impl<N: Network> PendingTxInterest<N> {
2336    fn matches_hash_only(&self) -> bool {
2337        !self.full_transactions
2338            && self.from.is_any()
2339            && self.to.is_any()
2340            && self.selectors.is_any()
2341            && self.local_matcher.is_none()
2342    }
2343
2344    fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
2345        self.from.matches(tx.from())
2346            && self.to.matches_option(tx.to())
2347            && self.selectors.matches(tx.input())
2348            && self
2349                .local_matcher
2350                .as_ref()
2351                .is_none_or(|matcher| matcher.matches(tx))
2352    }
2353}
2354
2355impl<N: Network> fmt::Debug for PendingTxInterest<N> {
2356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2357        f.debug_struct("PendingTxInterest")
2358            .field("full_transactions", &self.full_transactions)
2359            .field("from", &self.from)
2360            .field("to", &self.to)
2361            .field("selectors", &self.selectors)
2362            .field(
2363                "local_matcher",
2364                &self.local_matcher.as_ref().map(|_| "<matcher>"),
2365            )
2366            .finish()
2367    }
2368}
2369
2370/// Address matching helper for pending transaction interests.
2371#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2372pub enum AddressMatcher {
2373    /// Match every address.
2374    Any,
2375    /// Match one address.
2376    Exact(Address),
2377    /// Match any address in the list.
2378    AnyOf(Vec<Address>),
2379}
2380
2381impl AddressMatcher {
2382    /// Return true when the matcher is unconstrained.
2383    pub fn is_any(&self) -> bool {
2384        matches!(self, Self::Any)
2385    }
2386
2387    /// Match a present address.
2388    pub fn matches(&self, address: Address) -> bool {
2389        match self {
2390            Self::Any => true,
2391            Self::Exact(expected) => *expected == address,
2392            Self::AnyOf(addresses) => addresses.contains(&address),
2393        }
2394    }
2395
2396    /// Match an optional address.
2397    pub fn matches_option(&self, address: Option<Address>) -> bool {
2398        match (self, address) {
2399            (Self::Any, _) => true,
2400            (_, Some(address)) => self.matches(address),
2401            _ => false,
2402        }
2403    }
2404}
2405
2406/// Calldata selector matching helper.
2407#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2408pub enum SelectorMatcher {
2409    /// Match every selector.
2410    Any,
2411    /// Match any selector in the list.
2412    AnyOf(Vec<[u8; 4]>),
2413}
2414
2415impl SelectorMatcher {
2416    /// Return true when the matcher is unconstrained.
2417    pub fn is_any(&self) -> bool {
2418        matches!(self, Self::Any)
2419    }
2420
2421    /// Match calldata bytes.
2422    pub fn matches(&self, input: &Bytes) -> bool {
2423        match self {
2424            Self::Any => true,
2425            Self::AnyOf(selectors) => input
2426                .get(..4)
2427                .and_then(|bytes| bytes.try_into().ok())
2428                .is_some_and(|selector| selectors.contains(&selector)),
2429        }
2430    }
2431}
2432
2433/// Local predicate over a full pending transaction.
2434pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
2435    /// Return true when the transaction should be routed to the handler.
2436    fn matches(&self, tx: &N::TransactionResponse) -> bool;
2437}
2438
2439/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4).
2440///
2441/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so
2442/// liveness strategy is per-contract:
2443///
2444/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root
2445///   churn on nearly every block, so the root is a noisy gate — [`Slots`] opts
2446///   out. Its enumerated slots stay fresh via decoders + cadence reconcile.
2447/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has
2448///   `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root
2449///   each canonical block; a move a decoder did not cover is a coverage gap.
2450///
2451/// A false-positive resync is never *incorrect* — it costs one batched read — so
2452/// the policy is a **pure cost knob**, not a correctness lever.
2453///
2454/// [`Slots`]: TrackingPolicy::Slots
2455/// [`WholeAccount`]: TrackingPolicy::WholeAccount
2456#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2457#[non_exhaustive]
2458pub enum TrackingPolicy {
2459    /// Sparse interest (e.g. WETH: a few balance slots). The root churns on
2460    /// nearly every block, so it is a noisy gate — this policy is **never**
2461    /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders
2462    /// and cadence reconcile.
2463    Slots {
2464        /// The enumerated storage slots of interest.
2465        slots: Vec<U256>,
2466    },
2467    /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so
2468    /// the root is a tight, cheap gate: probe each canonical block; on a move no
2469    /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a
2470    /// [`ResyncReason::RootMoved`] repair.
2471    WholeAccount,
2472    /// Balance / nonce / code-hash only — resolved from the same `get_proof`
2473    /// response's account fields; no storage interest. Native balance/nonce
2474    /// changes do **not** move the storage root, so this policy compares the
2475    /// account fields directly across blocks rather than root-gating.
2476    Scalars,
2477}
2478
2479/// How often the reactive root gate probes tracked accounts
2480/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the
2481/// `Scalars` account-fields comparison rides the same firing).
2482///
2483/// `eth_getProof` is the slowest read this crate issues, so per-block probing
2484/// is never the default. Skipping blocks is safe by construction: the gate
2485/// diffs `root_now` against its **persisted baseline**, never
2486/// block-over-block, so a move in any skipped block is still visible at the
2487/// next firing — cadence trades detection lag (at most `n − 1` blocks) for
2488/// cost, never eventual detection. The decoder-touched set accumulates across
2489/// skipped blocks and drains per firing, so a covered write in a skipped
2490/// block never false-positives as a [`ReactiveReport::CoverageGap`].
2491#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2492pub enum RootGateCadence {
2493    /// Probe at most once every `n` canonical blocks (the first canonical
2494    /// block ever seen always fires, so baseline adoption does not wait a
2495    /// full window). `EveryNBlocks(1)` is per-block probing.
2496    EveryNBlocks(NonZeroU64),
2497    /// Root gate off: coverage gaps surface only via decoders + freshness.
2498    Disabled,
2499}
2500
2501impl RootGateCadence {
2502    /// Probe at most once every `n` canonical blocks, clamping `0` to `1`.
2503    pub fn every_n_blocks(n: u64) -> Self {
2504        Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
2505    }
2506}
2507
2508impl Default for RootGateCadence {
2509    /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on
2510    /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise*
2511    /// `n`, not lower it.
2512    fn default() -> Self {
2513        Self::every_n_blocks(16)
2514    }
2515}
2516
2517/// Per-account baseline held by the root gate: the last observed on-chain root
2518/// and account fields, plus the block they were observed at.
2519///
2520/// The gate diffs the on-chain root **across time** (never local-vs-chain, per
2521/// spec §6): it persists the *observed* root as a baseline and compares
2522/// `root_now` to it. This is a currency gate, not a completeness gate.
2523#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2524struct TrackedRoot {
2525    last_root: B256,
2526    last_block: u64,
2527    balance: U256,
2528    nonce: u64,
2529    code_hash: B256,
2530}
2531
2532/// Request for authoritative state repair.
2533#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2534pub struct ResyncRequest {
2535    /// Resync id.
2536    pub id: ResyncId,
2537    /// Reason for the request.
2538    pub reason: ResyncReason,
2539    /// Block selection for the read.
2540    pub block: ResyncBlock,
2541    /// Targets to resync.
2542    pub targets: Vec<ResyncTarget>,
2543    /// Scheduling priority.
2544    pub priority: ResyncPriority,
2545}
2546
2547/// Resync id.
2548#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2549pub struct ResyncId(String);
2550
2551impl ResyncId {
2552    /// Create a resync id.
2553    pub fn new(id: impl Into<String>) -> Self {
2554        Self(id.into())
2555    }
2556}
2557
2558/// Reason for a resync request.
2559#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2560#[non_exhaustive]
2561pub enum ResyncReason {
2562    /// Handler requested repair.
2563    HandlerRequested,
2564    /// State effect could not be applied completely.
2565    SkippedStateEffect,
2566    /// A missed block range was detected; caller-scheduled repair.
2567    ///
2568    /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed
2569    /// range (there are no known targets to resync). This reason is provided so a
2570    /// caller building its own repair in response to a
2571    /// [`ReactiveReport::MissedBlockRange`] can attribute it.
2572    MissedBlockRange,
2573    /// A tracked account's storage root moved with no covering decoder.
2574    ///
2575    /// Emitted by the per-block root gate (Phase-8 step 4). A
2576    /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's
2577    /// `storageHash` moved between the adopted baseline and the current canonical
2578    /// block, yet no decoder wrote that account during the block — a coverage gap.
2579    /// The gate schedules a resync with this reason to re-read the account
2580    /// authoritatively and self-heal the blind spot. Also used for the
2581    /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path.
2582    RootMoved,
2583    /// Caller-defined reason.
2584    Custom(String),
2585}
2586
2587/// Block target for a resync.
2588#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2589pub enum ResyncBlock {
2590    /// Latest block.
2591    Latest,
2592    /// Current provider pre-confirmation state.
2593    Pending,
2594    /// Safe head.
2595    Safe,
2596    /// Finalized head.
2597    Finalized,
2598    /// Block number.
2599    Number(u64),
2600    /// Block hash and number.
2601    Hash {
2602        /// Block number.
2603        number: u64,
2604        /// Block hash.
2605        hash: B256,
2606        /// Require the hash to still be canonical.
2607        require_canonical: bool,
2608    },
2609}
2610
2611/// State target for a resync.
2612#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2613pub enum ResyncTarget {
2614    /// One storage slot.
2615    StorageSlot {
2616        /// Contract address.
2617        address: Address,
2618        /// Storage slot.
2619        slot: U256,
2620    },
2621    /// Multiple storage slots on one contract.
2622    StorageSlots {
2623        /// Contract address.
2624        address: Address,
2625        /// Storage slots.
2626        slots: Vec<U256>,
2627    },
2628    /// Account fields.
2629    Account {
2630        /// Account address.
2631        address: Address,
2632        /// Fields to resync.
2633        fields: AccountFieldMask,
2634    },
2635}
2636
2637/// Account fields requested by a resync.
2638#[derive(
2639    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
2640)]
2641pub struct AccountFieldMask {
2642    /// Balance field.
2643    pub balance: bool,
2644    /// Nonce field.
2645    pub nonce: bool,
2646    /// Code field.
2647    pub code: bool,
2648}
2649
2650/// Resync priority.
2651#[derive(
2652    Clone,
2653    Copy,
2654    Debug,
2655    Default,
2656    PartialEq,
2657    Eq,
2658    Hash,
2659    PartialOrd,
2660    Ord,
2661    serde::Serialize,
2662    serde::Deserialize,
2663)]
2664pub enum ResyncPriority {
2665    /// Low priority.
2666    Low,
2667    /// Normal priority.
2668    #[default]
2669    Normal,
2670    /// High priority.
2671    High,
2672}
2673
2674/// Rich invalidation request lowered to [`StateUpdate::Purge`].
2675#[derive(Clone, Debug, PartialEq, Eq)]
2676pub struct InvalidationRequest {
2677    /// Purge scope.
2678    pub scope: PurgeScope,
2679    /// Address to purge.
2680    pub address: Address,
2681    /// Reason for reporting.
2682    pub reason: InvalidationReason,
2683}
2684
2685/// Invalidation reason.
2686#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2687pub enum InvalidationReason {
2688    /// Handler requested invalidation.
2689    HandlerRequested,
2690    /// Reorg invalidation.
2691    Reorg,
2692    /// Caller-defined reason.
2693    Custom(String),
2694}
2695
2696/// Speculative signal emitted by handlers.
2697#[derive(Clone, Debug, PartialEq, Eq)]
2698pub struct SpeculativeRequest {
2699    /// Speculative request id.
2700    pub id: SpeculativeId,
2701    /// Input that triggered the request.
2702    pub input_ref: InputRef,
2703    /// Labels for downstream routing.
2704    pub labels: Vec<ReportTag>,
2705}
2706
2707/// Speculative request id.
2708#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2709pub struct SpeculativeId(String);
2710
2711impl SpeculativeId {
2712    /// Create a speculative id.
2713    pub fn new(id: impl Into<String>) -> Self {
2714        Self(id.into())
2715    }
2716}
2717
2718/// Configuration for [`ReactiveRuntime`].
2719#[derive(Clone, Debug, PartialEq, Eq)]
2720pub struct ReactiveConfig {
2721    /// Hook backpressure policy. **Reserved — currently has no effect.** Hook
2722    /// dispatch is synchronous today (every report is delivered to every hook in
2723    /// order), so this field is a no-op placeholder for a future async dispatcher.
2724    /// Setting it to anything other than the default does not change behavior.
2725    pub hook_backpressure: HookBackpressure,
2726    /// Reorg journal depth: the number of recent canonical blocks whose effects
2727    /// are journaled for rollback. This is **load-bearing** for reorg recovery:
2728    /// only blocks still resident in the journal can be recovered. A reorg deeper
2729    /// than `journal_depth` recovers the blocks still in the journal and leaves
2730    /// the aged-out blocks' effects in place — they are **neither rolled back nor
2731    /// purged**, so the freshness/validation loop is the only backstop for that
2732    /// span. `0` disables journaling entirely: no reorg is rolled back or purged.
2733    ///
2734    /// Set `journal_depth` to exceed the deepest reorg you intend to recover
2735    /// precisely. When a reorg references a block that is no longer in the journal,
2736    /// the runtime emits a `tracing::warn!` so the under-recovery is observable
2737    /// rather than silent. Checkpointed engine ingestion is stricter: explicit
2738    /// reorgs, implicit parent replacements, and removed/reorged records whose
2739    /// rollback proof falls outside the retained effect journal are rejected
2740    /// before mutation, durable save, or acknowledgement. Align this depth with
2741    /// the complete reorg horizon promised by the subscriber.
2742    pub journal_depth: usize,
2743}
2744
2745impl Default for ReactiveConfig {
2746    fn default() -> Self {
2747        Self {
2748            hook_backpressure: HookBackpressure::Block,
2749            journal_depth: 64,
2750        }
2751    }
2752}
2753
2754/// Hook backpressure policy.
2755#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2756pub enum HookBackpressure {
2757    /// Block the producer until hooks are accepted.
2758    Block,
2759    /// Drop the newest report under pressure.
2760    DropNewest,
2761    /// Drop the oldest report under pressure.
2762    DropOldest,
2763    /// Return an error under pressure.
2764    Error,
2765}
2766
2767/// Queryable coarse health of the reactive cache.
2768///
2769/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a
2770/// degraded or unhealthy state when it detects that its recovery guarantees no
2771/// longer hold (for example a reorg that runs deeper than the journal, so some
2772/// dropped effects are neither rolled back nor purged). Later waves report
2773/// missed-range and coverage-gap conditions into the same state machine.
2774#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2775#[non_exhaustive]
2776pub enum CacheHealth {
2777    /// All recovery guarantees hold; the cache is fully self-consistent.
2778    #[default]
2779    Healthy,
2780    /// A recoverable inconsistency was detected (for example under-recovered
2781    /// reorg effects); `since_block` records the block that triggered the
2782    /// transition.
2783    Degraded {
2784        /// Block number at which the degradation was first observed.
2785        since_block: u64,
2786    },
2787    /// A more serious inconsistency was detected; `since_block` records the
2788    /// block that triggered the transition.
2789    Unhealthy {
2790        /// Block number at which the unhealthy condition was first observed.
2791        since_block: u64,
2792    },
2793}
2794
2795/// Point-in-time copy of the reactive runtime's observability counters.
2796///
2797/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically
2798/// increasing count over the lifetime of the runtime. Counters wired by later
2799/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict
2800/// tracking) remain zero until those waves land.
2801#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2802#[non_exhaustive]
2803pub struct CacheMetricsSnapshot {
2804    /// Reorgs that ran deeper than the journal, so aged-out effects could not be
2805    /// rolled back or purged.
2806    pub deep_reorgs: u64,
2807    /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs).
2808    pub reorgs_recovered: u64,
2809    /// Storage resync targets considered by the resync execution pass.
2810    pub resync_requests: u64,
2811    /// Storage resync targets that could not be fetched or applied.
2812    pub resync_failures: u64,
2813    /// Ranges of blocks the runtime detected it did not observe (reserved).
2814    pub missed_ranges: u64,
2815    /// Storage-hash coverage gaps detected (reserved).
2816    pub coverage_gaps: u64,
2817    /// Pending-source inputs that attempted a canonical cache effect.
2818    pub pending_contamination: u64,
2819    /// Verdicts served past their freshness horizon (reserved).
2820    pub stale_verdicts: u64,
2821}
2822
2823/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`].
2824///
2825/// Fields are [`AtomicU64`] so counters can be incremented behind a shared
2826/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`]
2827/// into a plain [`CacheMetricsSnapshot`].
2828#[derive(Debug, Default)]
2829struct CacheMetrics {
2830    deep_reorgs: AtomicU64,
2831    reorgs_recovered: AtomicU64,
2832    resync_requests: AtomicU64,
2833    resync_failures: AtomicU64,
2834    missed_ranges: AtomicU64,
2835    coverage_gaps: AtomicU64,
2836    pending_contamination: AtomicU64,
2837    stale_verdicts: AtomicU64,
2838}
2839
2840impl CacheMetrics {
2841    fn snapshot(&self) -> CacheMetricsSnapshot {
2842        CacheMetricsSnapshot {
2843            deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
2844            reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
2845            resync_requests: self.resync_requests.load(Ordering::Relaxed),
2846            resync_failures: self.resync_failures.load(Ordering::Relaxed),
2847            missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
2848            coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
2849            pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
2850            stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
2851        }
2852    }
2853
2854    fn restore(&self, snapshot: CacheMetricsSnapshot) {
2855        self.deep_reorgs
2856            .store(snapshot.deep_reorgs, Ordering::Relaxed);
2857        self.reorgs_recovered
2858            .store(snapshot.reorgs_recovered, Ordering::Relaxed);
2859        self.resync_requests
2860            .store(snapshot.resync_requests, Ordering::Relaxed);
2861        self.resync_failures
2862            .store(snapshot.resync_failures, Ordering::Relaxed);
2863        self.missed_ranges
2864            .store(snapshot.missed_ranges, Ordering::Relaxed);
2865        self.coverage_gaps
2866            .store(snapshot.coverage_gaps, Ordering::Relaxed);
2867        self.pending_contamination
2868            .store(snapshot.pending_contamination, Ordering::Relaxed);
2869        self.stale_verdicts
2870            .store(snapshot.stale_verdicts, Ordering::Relaxed);
2871    }
2872}
2873
2874/// Runtime report.
2875#[derive(Clone, Debug)]
2876#[non_exhaustive]
2877pub enum ReactiveReport<N: Network = Ethereum> {
2878    /// Input was accepted after deduplication.
2879    Input(InputReport<N>),
2880    /// Handlers produced outcomes.
2881    Decoded(DecodedReport<N>),
2882    /// Direct state effects were applied.
2883    Applied(AppliedReport<N>),
2884    /// Resync request was scheduled or completed.
2885    Resynced(ResyncReport),
2886    /// Block-level processing completed.
2887    BlockCommitted(BlockReport<N>),
2888    /// Reorg processing report.
2889    Reorg(ReorgReport<N>),
2890    /// Ordered source control accepted by the runtime.
2891    ChainControl(ChainControlReport),
2892    /// A forward gap in the canonical block sequence was detected: blocks between
2893    /// the last-seen head and an arriving block were never observed.
2894    MissedBlockRange(MissedRangeReport<N>),
2895    /// Cache health transitioned between states.
2896    Health(HealthReport<N>),
2897    /// A tracked account's storage root moved with no covering decoder — a
2898    /// coverage gap the per-block root gate detected (Phase-8 step 4).
2899    CoverageGap(CoverageGapReport<N>),
2900    /// Runtime or handler error.
2901    Error(ReactiveErrorReport<N>),
2902}
2903
2904/// Report emitted after an ordered source control is accepted.
2905#[derive(Clone, Debug, PartialEq, Eq)]
2906pub struct ChainControlReport {
2907    /// Control in its original delivery order.
2908    pub control: ChainControl,
2909}
2910
2911/// Input acceptance report.
2912#[derive(Clone, Debug)]
2913pub struct InputReport<N: Network = Ethereum> {
2914    /// Input reference.
2915    pub input_ref: InputRef,
2916    /// Input context.
2917    pub context: ReactiveContext,
2918    /// Provider session that originated the input, when known.
2919    pub provider: Option<ProviderRef>,
2920    /// Network marker.
2921    pub _network: PhantomData<N>,
2922}
2923
2924/// Decoding report.
2925#[derive(Clone, Debug)]
2926pub struct DecodedReport<N: Network = Ethereum> {
2927    /// Input reference.
2928    pub input_ref: InputRef,
2929    /// Handler ids that matched the input.
2930    pub handler_ids: Vec<HandlerId>,
2931    /// Network marker.
2932    pub _network: PhantomData<N>,
2933}
2934
2935/// Applied state report.
2936#[derive(Clone, Debug)]
2937pub struct AppliedReport<N: Network = Ethereum> {
2938    /// Input reference.
2939    pub input_ref: InputRef,
2940    /// Handler that produced the applied effects.
2941    pub handler_id: HandlerId,
2942    /// State effect quality.
2943    pub quality: StateEffectQuality,
2944    /// Labels emitted by the handler.
2945    pub tags: Vec<ReportTag>,
2946    /// Merged state diff from applied updates and invalidations.
2947    pub diff: StateDiff,
2948    /// State updates applied through the cache.
2949    pub state_updates: Vec<StateUpdate>,
2950    /// Invalidation requests lowered to purge updates.
2951    pub invalidations: Vec<InvalidationRequest>,
2952    /// Resync requests surfaced for a scheduler.
2953    pub resyncs: Vec<ResyncRequest>,
2954    /// Speculative requests surfaced for downstream users.
2955    pub speculative: Vec<SpeculativeRequest>,
2956    /// Hook signals emitted by the handler.
2957    pub hook_signals: Vec<HookSignal>,
2958    /// Network marker.
2959    pub _network: PhantomData<N>,
2960}
2961
2962/// Report of the storage resync requests executed during an ingest cycle: the
2963/// requests considered, the authoritative updates built from successful fetches
2964/// (and their applied diff), and any targets that could not be resynced.
2965#[derive(Clone, Debug, Default, PartialEq, Eq)]
2966pub struct ResyncReport {
2967    /// Requests considered by the resync execution pass.
2968    pub requested: Vec<ResyncRequest>,
2969    /// Authoritative state updates built from successful resync fetches.
2970    pub state_updates: Vec<StateUpdate>,
2971    /// Diff returned by applying [`state_updates`](Self::state_updates).
2972    pub diff: StateDiff,
2973    /// Targets that could not be resynced.
2974    pub failed: Vec<ResyncFailure>,
2975}
2976
2977/// One resync target that could not be fetched or applied.
2978#[derive(Clone, Debug, PartialEq, Eq)]
2979pub struct ResyncFailure {
2980    /// Request that produced the failed target.
2981    pub request_id: ResyncId,
2982    /// Block selection used for the failed target.
2983    pub block: ResyncBlock,
2984    /// Target that could not be resynced.
2985    pub target: ResyncTarget,
2986    /// Stable failure classification for retry policy and metrics.
2987    pub kind: ResyncFailureKind,
2988    /// Human-readable failure reason.
2989    pub message: String,
2990}
2991
2992/// Stable classification for a failed resync target.
2993#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2994#[non_exhaustive]
2995pub enum ResyncFailureKind {
2996    /// A storage target could not be fetched because no storage batch fetcher is configured.
2997    MissingStorageFetcher,
2998    /// The storage batch fetcher returned an error for the requested slot.
2999    StorageFetchFailed,
3000    /// The storage batch fetcher did not return a result for the requested slot.
3001    StorageFetchOmitted,
3002    /// An account target could not be fetched because no account proof fetcher is configured.
3003    MissingAccountFetcher,
3004    /// The account proof fetcher returned an error for the requested address.
3005    AccountFetchFailed,
3006    /// The account proof fetcher did not return a result for the requested address.
3007    AccountFetchOmitted,
3008}
3009
3010/// Block processing report.
3011#[derive(Clone, Debug)]
3012pub struct BlockReport<N: Network = Ethereum> {
3013    /// Block reference, when known.
3014    pub block: Option<BlockRef>,
3015    /// Input references committed for the block.
3016    pub inputs: Vec<InputRef>,
3017    /// Network marker.
3018    pub _network: PhantomData<N>,
3019}
3020
3021/// Report of a detected reorg and the recovery it performed: the dropped
3022/// block(s) and inputs, the exact rollback updates applied for reversible dropped
3023/// effects, the conservative purge updates for irreversible ones, the canceled
3024/// hash-pinned resyncs, and why recovery ran.
3025///
3026/// Recovery only covers blocks still resident in the journal. If a reorg runs
3027/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not
3028/// appear here and their effects are neither rolled back nor purged (the runtime
3029/// logs a `tracing::warn!` in that case); the freshness/validation loop is the
3030/// backstop for that span. Checkpointed engine ingestion rejects explicit,
3031/// implicit-parent, and removed-log recovery outside the retained journal
3032/// instead of producing and durably acknowledging a partial report.
3033/// Non-checkpointed ingestion still emits this report when no journal entry was
3034/// recoverable; in that case `dropped` identifies the signal/head when known,
3035/// while `dropped_blocks` and rollback effects are empty.
3036#[derive(Clone, Debug)]
3037pub struct ReorgReport<N: Network = Ethereum> {
3038    /// First dropped block, when known.
3039    pub dropped: Option<BlockRef>,
3040    /// Blocks dropped from the journal, in ascending journal order.
3041    pub dropped_blocks: Vec<BlockRef>,
3042    /// Input references that belonged to dropped blocks.
3043    pub dropped_inputs: Vec<InputRef>,
3044    /// Exact rollback updates applied for reversible dropped effects.
3045    pub rollback_updates: Vec<StateUpdate>,
3046    /// Diff returned by applying [`rollback_updates`](Self::rollback_updates).
3047    pub rollback_diff: StateDiff,
3048    /// Conservative purge updates applied for irreversible dropped effects.
3049    pub purge_updates: Vec<StateUpdate>,
3050    /// Diff returned by applying [`purge_updates`](Self::purge_updates).
3051    pub purge_diff: StateDiff,
3052    /// Hash-pinned pending resync requests canceled because their block was dropped.
3053    pub canceled_resyncs: Vec<ResyncRequest>,
3054    /// Reorg trigger.
3055    pub reason: ReorgReason,
3056    /// Network marker.
3057    pub _network: PhantomData<N>,
3058}
3059
3060/// Reason reorg recovery ran.
3061#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3062pub enum ReorgReason {
3063    /// A provider emitted an Alloy removed log.
3064    RemovedLog,
3065    /// The input context explicitly marked an input as reorged.
3066    ReorgedInput,
3067    /// A canonical block did not connect to the journaled head.
3068    ParentMismatch,
3069    /// A subscriber delivered an explicit canonical branch transition.
3070    Explicit,
3071}
3072
3073/// Report of a forward gap in the canonical block sequence: an arriving block
3074/// whose number is more than one past the last-seen head, so the blocks in
3075/// between were never observed (for example during a subscription disconnect).
3076///
3077/// The arriving block is still accepted and applied — the chain extends — so this
3078/// report only makes the skipped span observable; it does not drop the block. The
3079/// span `from..=to` is inclusive of both endpoints.
3080#[derive(Clone, Debug)]
3081pub struct MissedRangeReport<N: Network = Ethereum> {
3082    /// First skipped block (`last-seen block number + 1`).
3083    pub from: u64,
3084    /// Last skipped block (`arriving block number - 1`).
3085    pub to: u64,
3086    /// The arriving block's number.
3087    pub block: u64,
3088    /// Network marker.
3089    pub _network: PhantomData<N>,
3090}
3091
3092/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that
3093/// caused it and delivered to hooks through the normal dispatch path.
3094#[derive(Clone, Debug)]
3095pub struct HealthReport<N: Network = Ethereum> {
3096    /// Health state before the transition.
3097    pub from: CacheHealth,
3098    /// Health state after the transition.
3099    pub to: CacheHealth,
3100    /// Block number associated with the transition, when known.
3101    pub block: Option<u64>,
3102    /// Network marker.
3103    pub _network: PhantomData<N>,
3104}
3105
3106/// Report that a tracked account's storage root moved on a canonical block that
3107/// no decoder covered — a coverage gap surfaced by the per-block root gate
3108/// (Phase-8 step 4).
3109///
3110/// An account's `storageHash` is a collision-resistant commitment over all of its
3111/// storage, so a moved root proves *something* under the account changed. When
3112/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the
3113/// batch's touched-address set does not include it, the change arrived through a
3114/// path no decoder observed. The runtime emits this report (delivered through the
3115/// normal dispatch path so [`ReactiveHook::on_report`] observers see it),
3116/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a
3117/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively.
3118#[derive(Clone, Debug)]
3119pub struct CoverageGapReport<N: Network = Ethereum> {
3120    /// The tracked account whose root moved with no covering decoder.
3121    pub address: Address,
3122    /// The canonical block number at which the gap was observed.
3123    pub block: u64,
3124    /// Network marker.
3125    pub _network: PhantomData<N>,
3126}
3127
3128/// Report of a non-fatal error surfaced during an ingest cycle, with the
3129/// associated input (when known) and a human-readable message.
3130#[derive(Clone, Debug)]
3131pub struct ReactiveErrorReport<N: Network = Ethereum> {
3132    /// Input associated with the error, when known.
3133    pub input_ref: Option<InputRef>,
3134    /// Error message.
3135    pub message: String,
3136    /// Network marker.
3137    pub _network: PhantomData<N>,
3138}
3139
3140/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and
3141/// [`ReactiveRuntime::ingest_batch_with_resync`].
3142#[derive(Clone, Debug)]
3143pub struct ReactiveBatchReport<N: Network = Ethereum> {
3144    /// Applied reports in commit order.
3145    pub applied: Vec<AppliedReport<N>>,
3146    /// Resync requests surfaced during the batch.
3147    pub resyncs: Vec<ResyncRequest>,
3148    /// Speculative requests surfaced during the batch.
3149    pub speculative: Vec<SpeculativeRequest>,
3150    /// Hook reports dispatched after mutation phases.
3151    pub reports: Vec<Arc<ReactiveReport<N>>>,
3152}
3153
3154impl<N: Network> Default for ReactiveBatchReport<N> {
3155    fn default() -> Self {
3156        Self {
3157            applied: Vec::new(),
3158            resyncs: Vec::new(),
3159            speculative: Vec::new(),
3160            reports: Vec::new(),
3161        }
3162    }
3163}
3164
3165/// Error returned by a handler.
3166#[derive(Clone, Debug, PartialEq, Eq)]
3167pub struct HandlerError {
3168    message: String,
3169}
3170
3171impl HandlerError {
3172    /// Create a handler error from a message.
3173    pub fn new(message: impl Into<String>) -> Self {
3174        Self {
3175            message: message.into(),
3176        }
3177    }
3178}
3179
3180impl fmt::Display for HandlerError {
3181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3182        self.message.fmt(f)
3183    }
3184}
3185
3186impl std::error::Error for HandlerError {}
3187
3188impl From<String> for HandlerError {
3189    fn from(message: String) -> Self {
3190        Self::new(message)
3191    }
3192}
3193
3194impl From<&str> for HandlerError {
3195    fn from(message: &str) -> Self {
3196        Self::new(message)
3197    }
3198}
3199
3200/// Runtime error.
3201#[derive(Debug, thiserror::Error)]
3202#[non_exhaustive]
3203pub enum ReactiveError {
3204    /// Handler returned an error.
3205    #[error("handler `{handler_id}` failed: {source}")]
3206    HandlerFailed {
3207        /// Handler id.
3208        handler_id: HandlerId,
3209        /// Handler error.
3210        source: HandlerError,
3211    },
3212    /// Multiple handlers emitted incompatible absolute writes for one input.
3213    #[error(
3214        "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
3215    )]
3216    ConflictingEffects {
3217        /// Input reference.
3218        input_ref: Box<InputRef>,
3219        /// Conflicting target.
3220        target: Box<EffectTarget>,
3221        /// First handler id.
3222        first: HandlerId,
3223        /// Second handler id.
3224        second: HandlerId,
3225    },
3226    /// Pending inputs attempted to mutate canonical cache state.
3227    #[error(
3228        "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
3229    )]
3230    InvalidPendingEffect {
3231        /// Input reference.
3232        input_ref: Box<InputRef>,
3233        /// Handler id.
3234        handler_id: HandlerId,
3235        /// Effect kind.
3236        effect_kind: &'static str,
3237    },
3238    /// A subscriber supplied payload metadata that is incomplete or
3239    /// contradicts the accompanying context.
3240    #[error("invalid reactive input record: {message}")]
3241    InvalidInputRecord {
3242        /// Human-readable invariant violation.
3243        message: String,
3244    },
3245    /// A source delivered a contradictory chain-lifecycle transition.
3246    #[error("invalid chain control: {message}")]
3247    InvalidChainControl {
3248        /// Human-readable invariant violation.
3249        message: String,
3250    },
3251    /// Owner-scoped catch-up would mutate a historical block for which the
3252    /// runtime has no rollback journal entry.
3253    #[error(
3254        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3255    )]
3256    OwnerCatchupOutsideJournal {
3257        /// Catch-up block number.
3258        number: u64,
3259        /// Catch-up block hash.
3260        hash: B256,
3261    },
3262    /// Registration error.
3263    #[error(transparent)]
3264    Register(#[from] RegisterError),
3265}
3266
3267/// Handler registration error.
3268#[derive(Debug, thiserror::Error)]
3269#[non_exhaustive]
3270pub enum RegisterError {
3271    /// Duplicate handler id.
3272    #[error("handler id `{0}` is already registered")]
3273    DuplicateHandler(HandlerId),
3274}
3275
3276/// Error returned when [`ReactiveEngine`] cannot register a handler on both the
3277/// runtime and subscriber sides.
3278#[derive(Debug, thiserror::Error)]
3279#[non_exhaustive]
3280pub enum ReactiveEngineRegisterError {
3281    /// Runtime registry rejected the handler.
3282    #[error(transparent)]
3283    Register(#[from] RegisterError),
3284    /// Subscriber rejected the handler's interests.
3285    #[error(transparent)]
3286    Subscriber(#[from] SubscriberError),
3287    /// Owner-only history was not constrained to one hash-certified block that
3288    /// remains in the runtime rollback journal.
3289    #[error(
3290        "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
3291    )]
3292    BackfillOutsideJournal {
3293        /// First requested block.
3294        start_block: u64,
3295        /// Inclusive requested upper bound, if bounded.
3296        end_block: Option<u64>,
3297        /// Hash-certified anchor supplied by the caller, if any.
3298        retained_anchor: Option<BlockRef>,
3299    },
3300}
3301
3302/// Error adopting an RPC snapshot as a runtime's canonical continuity
3303/// baseline.
3304#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
3305#[non_exhaustive]
3306pub enum ReactiveBaselineError {
3307    /// Runtime or engine delivery state already contains lifecycle work.
3308    #[error("cannot adopt a canonical baseline after reactive processing has started")]
3309    ActiveRuntime,
3310    /// An exact repeat is allowed, but the requested baseline conflicts with
3311    /// the previously adopted block.
3312    #[error(
3313        "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
3314    )]
3315    ConflictingBaseline {
3316        /// Existing baseline number.
3317        existing_number: u64,
3318        /// Existing baseline hash.
3319        existing_hash: B256,
3320        /// Requested baseline number.
3321        requested_number: u64,
3322        /// Requested baseline hash.
3323        requested_hash: B256,
3324    },
3325    /// Typed baseline and cache identify different chains.
3326    #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
3327    CacheChainMismatch {
3328        /// Chain declared by the baseline.
3329        baseline_chain_id: u64,
3330        /// Chain configured on the cache.
3331        cache_chain_id: u64,
3332    },
3333    /// The cache is not hash-pinned to the exact adopted canonical block.
3334    #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
3335    CacheBlockMismatch {
3336        /// Expected baseline number.
3337        number: u64,
3338        /// Expected baseline hash.
3339        hash: B256,
3340    },
3341}
3342
3343/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling
3344/// and runtime ingestion.
3345#[derive(Debug, thiserror::Error)]
3346#[non_exhaustive]
3347pub enum ReactiveEngineError {
3348    /// Subscriber polling failed.
3349    #[error(transparent)]
3350    Subscriber(#[from] SubscriberError),
3351    /// Runtime ingestion failed.
3352    #[error(transparent)]
3353    Runtime(ReactiveError),
3354    /// Canonical cold-start baseline adoption failed.
3355    #[error(transparent)]
3356    Baseline(#[from] ReactiveBaselineError),
3357    /// Runtime ingestion succeeded, but its durable delivery acknowledgement
3358    /// did not commit. The subscriber may replay the batch.
3359    #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
3360    Acknowledgement(#[source] SubscriberError),
3361    /// Runtime ingestion succeeded, but the resulting cache state could not be
3362    /// durably checkpointed. The engine retains the commit in memory and must
3363    /// retry it before polling another batch.
3364    #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
3365    Checkpoint(#[source] DurableCheckpointError),
3366    /// A checkpointed ingest had no canonical block to bind the state to.
3367    #[error("cannot durably checkpoint reactive state before observing a canonical block")]
3368    MissingCheckpointBlock,
3369    /// Speculative pre-confirmation state is intentionally excluded from
3370    /// canonical durable checkpoints.
3371    #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
3372    PreconfirmationNotCheckpointable,
3373    /// Runtime rollback/finality state could not be encoded for the checkpoint.
3374    #[error("failed to encode durable reactive runtime state: {0}")]
3375    RuntimeCheckpoint(String),
3376    /// A crash-safe checkpoint commit is pending, so the engine cannot switch
3377    /// to ordinary acknowledgement ordering without first completing it.
3378    #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
3379    PendingCheckpointCommit,
3380    /// An ordinary delivery acknowledgement is pending, so the engine cannot
3381    /// switch to checkpointed ingestion and retroactively make it durable.
3382    #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
3383    PendingAcknowledgementCommit,
3384    /// A caller attempted to use a raw ingestion helper with subscriber-owned
3385    /// commit metadata. Only the combined polling helpers can preserve the
3386    /// required ingest-before-checkpoint-before-acknowledgement ordering.
3387    #[error(
3388        "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
3389    )]
3390    UncommittedDeliveryMetadata,
3391    /// Subscriber and cache are bound to different chains.
3392    #[error(
3393        "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
3394    )]
3395    SubscriberChainMismatch {
3396        /// Chain reported by the subscriber.
3397        subscriber_chain_id: u64,
3398        /// Chain configured on the cache.
3399        cache_chain_id: u64,
3400    },
3401    /// Crash-safe checkpoint APIs require durable replay/resume semantics.
3402    #[error("subscriber does not advertise durable replay support")]
3403    SubscriberNotDurable,
3404    /// A restored delivery token predates or otherwise lacks the core witness
3405    /// needed to prove that a replay carries the same delivery.
3406    #[error(
3407        "committed delivery token has no delivery witness; replay cannot be acknowledged safely"
3408    )]
3409    MissingReplayWitness,
3410    /// A source reused a committed token for different records, routing,
3411    /// controls, chain identity, or provider resume state.
3412    #[error("replayed delivery token does not match its committed delivery witness")]
3413    ReplayDeliveryMismatch,
3414    /// The stable delivery witness could not be encoded.
3415    #[error("failed to encode durable delivery witness: {0}")]
3416    DeliveryWitness(String),
3417    /// A tokened network-generic header/body cannot be witnessed completely
3418    /// without a source-supplied canonical wire commitment.
3419    #[error(
3420        "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
3421    )]
3422    MissingPayloadCommitment,
3423    /// Cache state changed after a batch was staged for a checkpoint. Retrying
3424    /// would bind those unrelated mutations to the older delivery metadata.
3425    #[error(
3426        "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
3427    )]
3428    PendingCheckpointCacheChanged {
3429        /// Generation immediately after the staged batch was ingested.
3430        staged_generation: u64,
3431        /// Generation observed when checkpoint commit was retried.
3432        current_generation: u64,
3433    },
3434    /// Checkpointed ingestion cannot durably acknowledge a reorg when the
3435    /// runtime no longer retains every potentially affected journal entry.
3436    #[error(
3437        "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
3438    )]
3439    CheckpointReorgOutsideJournal {
3440        /// Last block shared by the old and replacement branches.
3441        common_ancestor: u64,
3442        /// Oldest retained effect-bearing journal block, if any.
3443        oldest_journaled: Option<u64>,
3444        /// Configured maximum journal entries.
3445        journal_depth: usize,
3446    },
3447    /// Owner-scoped catch-up would mutate a historical block for which the
3448    /// runtime has no rollback journal entry.
3449    #[error(
3450        "owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
3451    )]
3452    OwnerCatchupOutsideJournal {
3453        /// Catch-up block number.
3454        number: u64,
3455        /// Catch-up block hash.
3456        hash: B256,
3457    },
3458}
3459
3460impl From<ReactiveError> for ReactiveEngineError {
3461    fn from(error: ReactiveError) -> Self {
3462        match error {
3463            ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
3464                Self::OwnerCatchupOutsideJournal { number, hash }
3465            }
3466            error => Self::Runtime(error),
3467        }
3468    }
3469}
3470
3471/// Error restoring a durable checkpoint anchor into an active runtime.
3472#[derive(Debug, thiserror::Error)]
3473#[non_exhaustive]
3474pub enum ReactiveCheckpointRestoreError {
3475    /// A runtime with canonical journal state cannot be silently rewound.
3476    #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
3477    ActiveRuntime,
3478    /// Stored runtime recovery bytes were malformed or unsupported.
3479    #[error("invalid durable reactive runtime state: {0}")]
3480    InvalidRuntimeCheckpoint(String),
3481    /// Checkpoint identity or cache restoration failed before activation.
3482    #[error(transparent)]
3483    Checkpoint(#[from] DurableCheckpointError),
3484    /// Subscriber rejected the restored durable cursor or canonical position.
3485    #[error("subscriber rejected durable resume position: {0}")]
3486    Subscriber(#[source] SubscriberError),
3487    /// Subscriber and checkpoint identities name different chains.
3488    #[error(
3489        "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
3490    )]
3491    SubscriberChainMismatch {
3492        /// Chain reported by the subscriber.
3493        subscriber_chain_id: u64,
3494        /// Chain committed by the checkpoint identity.
3495        checkpoint_chain_id: u64,
3496    },
3497    /// Restoring event continuity requires a durable replay-capable subscriber.
3498    #[error("subscriber does not advertise durable replay support")]
3499    SubscriberNotDurable,
3500}
3501
3502/// Result of one crash-safe subscriber ingest cycle.
3503#[derive(Clone, Debug)]
3504#[non_exhaustive]
3505pub enum CheckpointedIngest<N: Network = Ethereum> {
3506    /// A new batch was ingested, durably checkpointed, and acknowledged.
3507    Applied(ReactiveBatchReport<N>),
3508    /// The checkpoint already contained this replayed delivery token, so the
3509    /// batch was acknowledged without applying its effects twice.
3510    ReplayAcknowledged,
3511}
3512
3513/// Absolute write target used for conflict reports.
3514#[derive(Clone, Debug, PartialEq, Eq, Hash)]
3515pub enum EffectTarget {
3516    /// Storage slot target.
3517    StorageSlot {
3518        /// Contract address.
3519        address: Address,
3520        /// Storage slot.
3521        slot: U256,
3522    },
3523    /// Account balance target.
3524    AccountBalance {
3525        /// Account address.
3526        address: Address,
3527    },
3528    /// Account nonce target.
3529    AccountNonce {
3530        /// Account address.
3531        address: Address,
3532    },
3533    /// Account code target.
3534    AccountCode {
3535        /// Account address.
3536        address: Address,
3537    },
3538    /// Masked storage slot target.
3539    MaskedStorageSlot {
3540        /// Contract address.
3541        address: Address,
3542        /// Storage slot.
3543        slot: U256,
3544        /// Bit mask.
3545        mask: U256,
3546    },
3547}
3548
3549#[derive(Clone, Debug, PartialEq, Eq)]
3550enum AbsoluteValue {
3551    U256(U256),
3552    U64(u64),
3553    Bytes(Bytes),
3554}
3555
3556/// Reactive runtime.
3557pub struct ReactiveRuntime<N: Network = Ethereum> {
3558    registry: ReactiveRegistry<N>,
3559    hooks: Vec<Arc<dyn ReactiveHook<N>>>,
3560    config: ReactiveConfig,
3561    journal: VecDeque<BlockJournal<N>>,
3562    coverage_head: Option<BlockRef>,
3563    pending_resyncs: Vec<ResyncRequest>,
3564    health: CacheHealth,
3565    safe_head: Option<BlockRef>,
3566    finalized_head: Option<BlockRef>,
3567    metrics: CacheMetrics,
3568    /// Opt-in freshness registry the runtime stamps for canonical event writes.
3569    ///
3570    /// `None` by default (behavior unchanged); populated by
3571    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When
3572    /// present, applying a canonical handler storage-slot effect stamps the
3573    /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`
3574    /// so event-maintained slots stop being needlessly re-verified while aging to
3575    /// volatile once the clock passes `N`.
3576    freshness: Option<FreshnessRegistry>,
3577    /// Per-account tracking registry consulted by the per-block root gate
3578    /// (Phase-8 step 4). Empty by default; populated by
3579    /// [`track_account`](Self::track_account). When empty the gate is a no-op.
3580    tracking: HashMap<Address, TrackingPolicy>,
3581    /// Per-account root/field baselines the gate diffs against across blocks.
3582    /// Adopted on first probe and re-adopted on every observed move.
3583    tracked_roots: HashMap<Address, TrackedRoot>,
3584    /// How often the root gate fires (§6.2); see [`RootGateCadence`].
3585    root_gate_cadence: RootGateCadence,
3586    /// Canonical block of the last root-gate firing. `None` until the first
3587    /// firing (which happens at the first canonical block ever seen, so
3588    /// baseline adoption never waits a full cadence window).
3589    last_gate_block: Option<u64>,
3590    /// Union of decoder-touched addresses since the last root-gate firing,
3591    /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉
3592    /// touched" must judge against every covered write in the window, or a
3593    /// decoder-covered write in a skipped block would false-positive as a
3594    /// [`ReactiveReport::CoverageGap`].
3595    touched_since_gate: HashSet<Address>,
3596    /// Disposable pre-confirmation branch layered over the canonical cache.
3597    /// This is deliberately omitted from durable runtime checkpoints.
3598    preconfirmed_branch: Option<PreconfirmedBranch>,
3599}
3600
3601#[derive(Clone)]
3602struct PreconfirmedBranch {
3603    flashblock: FlashblockRef,
3604    canonical_cache: EvmCacheStateSnapshot,
3605}
3606
3607#[derive(Clone, Debug)]
3608struct BlockJournal<N: Network = Ethereum> {
3609    block: BlockRef,
3610    inputs: Vec<InputRef>,
3611    applied: Vec<AppliedReport<N>>,
3612    handler_ids: Vec<HandlerId>,
3613    resynced: Vec<ResyncReport>,
3614    rollback_diffs: Vec<StateDiff>,
3615}
3616
3617const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3;
3618
3619#[derive(serde::Serialize, serde::Deserialize)]
3620struct DurableRuntimeCheckpoint {
3621    version: u32,
3622    safe_head: Option<BlockRef>,
3623    finalized_head: Option<BlockRef>,
3624    health: CacheHealth,
3625    pending_resyncs: Vec<ResyncRequest>,
3626    coverage_head: Option<BlockRef>,
3627    journal: Vec<DurableBlockJournal>,
3628    freshness: Option<FreshnessRegistry>,
3629    tracking: HashMap<Address, TrackingPolicy>,
3630    tracked_roots: HashMap<Address, TrackedRoot>,
3631    root_gate_cadence: RootGateCadence,
3632    last_gate_block: Option<u64>,
3633    touched_since_gate: HashSet<Address>,
3634    metrics: CacheMetricsSnapshot,
3635}
3636
3637#[derive(serde::Serialize, serde::Deserialize)]
3638struct DurableBlockJournal {
3639    block: BlockRef,
3640    handler_ids: Vec<HandlerId>,
3641    rollback_diffs: Vec<StateDiff>,
3642}
3643
3644struct DurableRuntimeRestorePlan {
3645    checkpoint: Option<DurableRuntimeCheckpoint>,
3646    fallback_history: Vec<BlockRef>,
3647}
3648
3649impl DurableRuntimeRestorePlan {
3650    fn canonical_history(&self) -> Vec<BlockRef> {
3651        self.checkpoint.as_ref().map_or_else(
3652            || self.fallback_history.clone(),
3653            |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
3654        )
3655    }
3656}
3657
3658#[derive(Clone)]
3659struct ReactiveRuntimeState<N: Network> {
3660    journal: VecDeque<BlockJournal<N>>,
3661    coverage_head: Option<BlockRef>,
3662    pending_resyncs: Vec<ResyncRequest>,
3663    health: CacheHealth,
3664    safe_head: Option<BlockRef>,
3665    finalized_head: Option<BlockRef>,
3666    freshness: Option<FreshnessRegistry>,
3667    tracking: HashMap<Address, TrackingPolicy>,
3668    tracked_roots: HashMap<Address, TrackedRoot>,
3669    root_gate_cadence: RootGateCadence,
3670    last_gate_block: Option<u64>,
3671    touched_since_gate: HashSet<Address>,
3672    metrics: CacheMetricsSnapshot,
3673}
3674
3675#[derive(Clone)]
3676struct ChainControlState {
3677    journal_invalidated_from: Option<u64>,
3678    resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
3679}
3680
3681/// Canonical branch fragments already rolled back by the current atomic batch.
3682///
3683/// Providers commonly emit one removed notification per log after one signal
3684/// has already drained the complete dropped block (and every retained
3685/// descendant). Explicit reorg controls can be followed by the same redundant
3686/// lifecycle records. Exact identities decide whether removal recovery is
3687/// redundant; numeric spans are retained only as same-batch proof for a
3688/// parentless replacement after those exact journal entries were drained.
3689#[derive(Default)]
3690struct BatchDroppedCanonical {
3691    identities: HashSet<(u64, B256)>,
3692    implicit_spans: Vec<(u64, u64)>,
3693}
3694
3695impl BatchDroppedCanonical {
3696    fn covers_implicit_number(&self, number: u64) -> bool {
3697        self.implicit_spans
3698            .iter()
3699            .any(|(from, through)| number >= *from && number <= *through)
3700    }
3701
3702    fn contains(&self, block: &BlockRef) -> bool {
3703        self.identities.contains(&(block.number, block.hash))
3704    }
3705
3706    fn record_identity(&mut self, block: &BlockRef) {
3707        self.identities.insert((block.number, block.hash));
3708    }
3709
3710    fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
3711        self.identities.insert((old_tip.number, old_tip.hash));
3712    }
3713
3714    fn record_drained(&mut self, blocks: &[BlockRef]) {
3715        let Some(from) = blocks.iter().map(|block| block.number).min() else {
3716            return;
3717        };
3718        let through = blocks
3719            .iter()
3720            .map(|block| block.number)
3721            .max()
3722            .expect("a non-empty drained set has a maximum");
3723        self.implicit_spans.push((from, through));
3724        self.identities
3725            .extend(blocks.iter().map(|block| (block.number, block.hash)));
3726    }
3727}
3728
3729/// Registry and router for provider-neutral reactive handlers.
3730///
3731/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes
3732/// consolidated provider-side log filters for subscription setup, and routes
3733/// provider logs back to the exact matching log interests. Consolidated filters
3734/// may be safe supersets; [`Self::route_log`] always re-checks the original
3735/// [`LogInterest`] and its local matcher before returning a route.
3736pub struct ReactiveRegistry<N: Network = Ethereum> {
3737    handlers: BTreeMap<u128, RegisteredHandler<N>>,
3738    handler_positions: HashMap<HandlerId, u128>,
3739    next_handler_position: u128,
3740    indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
3741    fallback_log_handlers: BTreeSet<u128>,
3742    data_slice_shapes: HashMap<(usize, usize), usize>,
3743}
3744
3745struct RegisteredHandler<N: Network = Ethereum> {
3746    id: HandlerId,
3747    handler: Arc<dyn ReactiveHandler<N>>,
3748    interests: Vec<ReactiveInterest<N>>,
3749    has_log_interests: bool,
3750    log_route_index: Option<LogRouteIndex>,
3751}
3752
3753impl<N: Network> Default for ReactiveRegistry<N> {
3754    fn default() -> Self {
3755        Self::new()
3756    }
3757}
3758
3759impl<N: Network> ReactiveRegistry<N> {
3760    /// Create an empty registry.
3761    pub fn new() -> Self {
3762        Self {
3763            handlers: BTreeMap::new(),
3764            handler_positions: HashMap::new(),
3765            next_handler_position: 0,
3766            indexed_log_handlers: HashMap::new(),
3767            fallback_log_handlers: BTreeSet::new(),
3768            data_slice_shapes: HashMap::new(),
3769        }
3770    }
3771
3772    /// Register a handler, preserving registration order.
3773    ///
3774    /// Duplicate handler ids are rejected with
3775    /// [`RegisterError::DuplicateHandler`].
3776    ///
3777    /// # Errors
3778    ///
3779    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
3780    /// registered.
3781    pub fn register_handler(
3782        &mut self,
3783        handler: Arc<dyn ReactiveHandler<N>>,
3784    ) -> Result<(), RegisterError> {
3785        let id = handler.id();
3786        if self.handler_positions.contains_key(&id) {
3787            return Err(RegisterError::DuplicateHandler(id));
3788        }
3789        let interests = handler.interests();
3790        self.insert_handler_prepared(id, handler, interests);
3791        Ok(())
3792    }
3793
3794    fn insert_handler_prepared(
3795        &mut self,
3796        id: HandlerId,
3797        handler: Arc<dyn ReactiveHandler<N>>,
3798        interests: Vec<ReactiveInterest<N>>,
3799    ) {
3800        debug_assert!(!self.handler_positions.contains_key(&id));
3801        let has_log_interests = interests
3802            .iter()
3803            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
3804        let log_route_index = handler.log_route_index();
3805        if self.next_handler_position == u128::MAX {
3806            self.compact_handler_positions();
3807        }
3808        let position = self.next_handler_position;
3809        self.next_handler_position += 1;
3810        self.handler_positions.insert(id.clone(), position);
3811        if let Some(index) = &log_route_index {
3812            for key in index.keys() {
3813                if let LogRouteKey::DataSlice { offset, value } = key {
3814                    *self
3815                        .data_slice_shapes
3816                        .entry((*offset, value.len()))
3817                        .or_default() += 1;
3818                }
3819                self.indexed_log_handlers
3820                    .entry(key.clone())
3821                    .or_default()
3822                    .insert(position);
3823            }
3824        } else if has_log_interests {
3825            self.fallback_log_handlers.insert(position);
3826        }
3827        self.handlers.insert(
3828            position,
3829            RegisteredHandler {
3830                id,
3831                handler,
3832                interests,
3833                has_log_interests,
3834                log_route_index,
3835            },
3836        );
3837    }
3838
3839    /// Remove one handler by id, leaving all other handlers and interests intact.
3840    ///
3841    /// Returns the removed handler when the id was registered. Cache eviction is
3842    /// intentionally outside this API: unregistering stops future routing and
3843    /// decode for the handler only.
3844    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
3845        let position = self.handler_positions.remove(id)?;
3846        let registered = self.handlers.remove(&position)?;
3847        if let Some(index) = &registered.log_route_index {
3848            for key in index.keys() {
3849                let remove_bucket = self
3850                    .indexed_log_handlers
3851                    .get_mut(key)
3852                    .is_some_and(|owners| {
3853                        owners.remove(&position);
3854                        owners.is_empty()
3855                    });
3856                if remove_bucket {
3857                    self.indexed_log_handlers.remove(key);
3858                }
3859                if let LogRouteKey::DataSlice { offset, value } = key {
3860                    let shape = (*offset, value.len());
3861                    let remove_shape =
3862                        self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
3863                            *count -= 1;
3864                            *count == 0
3865                        });
3866                    if remove_shape {
3867                        self.data_slice_shapes.remove(&shape);
3868                    }
3869                }
3870            }
3871        } else {
3872            self.fallback_log_handlers.remove(&position);
3873        }
3874        Some(registered.handler)
3875    }
3876
3877    /// Return true when `id` is currently registered.
3878    pub fn contains_handler(&self, id: &HandlerId) -> bool {
3879        self.handler_positions.contains_key(id)
3880    }
3881
3882    /// Ids of all registered handlers, in registration (= routing) order.
3883    pub fn handler_ids(&self) -> Vec<HandlerId> {
3884        self.handlers
3885            .values()
3886            .map(|handler| handler.id.clone())
3887            .collect()
3888    }
3889
3890    /// Borrow the interests owned by one handler.
3891    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
3892        self.handler_positions
3893            .get(id)
3894            .and_then(|position| self.handlers.get(position))
3895            .map(|registered| registered.interests.as_slice())
3896    }
3897
3898    /// Return all registered interests in handler registration order.
3899    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
3900        self.handlers
3901            .values()
3902            .flat_map(|handler| handler.interests.clone())
3903            .collect()
3904    }
3905
3906    /// Return consolidated provider-side log filters.
3907    ///
3908    /// Filters are emitted in deterministic first-registration order by
3909    /// compatible block option. Within each returned filter, address and topic
3910    /// sets are unioned independently, which can intentionally overfetch. Use
3911    /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s.
3912    pub fn log_subscription_filters(&self) -> Vec<Filter> {
3913        let mut filters = Vec::new();
3914        for interest in self.log_interests() {
3915            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
3916        }
3917        filters
3918    }
3919
3920    /// Route a log to exact matching handler interests.
3921    ///
3922    /// Routes are returned in handler registration order. Each handler appears
3923    /// at most once for a log, using the first matching log interest declared by
3924    /// that handler.
3925    pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
3926        self.log_handler_candidates(log)
3927            .into_iter()
3928            .filter_map(|handler| handler.route_log(log))
3929            .collect()
3930    }
3931
3932    fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
3933        let mut indexed_positions = Vec::new();
3934        if let Some(indexed) = self
3935            .indexed_log_handlers
3936            .get(&LogRouteKey::Emitter(log.address()))
3937        {
3938            indexed_positions.extend(indexed.iter().copied());
3939        }
3940        for (index, value) in log.topics().iter().copied().enumerate() {
3941            if let Some(indexed) = self
3942                .indexed_log_handlers
3943                .get(&LogRouteKey::Topic { index, value })
3944            {
3945                indexed_positions.extend(indexed.iter().copied());
3946            }
3947        }
3948        let data = log.inner.data.data.as_ref();
3949        for &(offset, len) in self.data_slice_shapes.keys() {
3950            let Some(end) = offset.checked_add(len) else {
3951                continue;
3952            };
3953            let Some(value) = data.get(offset..end) else {
3954                continue;
3955            };
3956            if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
3957                offset,
3958                value: value.to_vec(),
3959            }) {
3960                indexed_positions.extend(indexed.iter().copied());
3961            }
3962        }
3963        if indexed_positions.is_empty() {
3964            if self.fallback_log_handlers.is_empty() {
3965                return Vec::new();
3966            }
3967            if !self.indexed_log_handlers.is_empty() {
3968                return self
3969                    .fallback_log_handlers
3970                    .iter()
3971                    .filter_map(|position| self.handlers.get(position))
3972                    .collect();
3973            }
3974            return self
3975                .handlers
3976                .values()
3977                .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
3978                .collect();
3979        }
3980
3981        indexed_positions.extend(self.fallback_log_handlers.iter().copied());
3982        indexed_positions.sort_unstable();
3983        indexed_positions.dedup();
3984        indexed_positions
3985            .into_iter()
3986            .filter_map(|position| self.handlers.get(&position))
3987            .collect()
3988    }
3989
3990    fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
3991        self.handlers.values()
3992    }
3993
3994    fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
3995        self.handlers.values().flat_map(|handler| {
3996            handler
3997                .interests
3998                .iter()
3999                .filter_map(|interest| match interest {
4000                    ReactiveInterest::Logs(interest) => Some(interest),
4001                    ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
4002                })
4003        })
4004    }
4005
4006    fn compact_handler_positions(&mut self) {
4007        let handlers = std::mem::take(&mut self.handlers);
4008        self.handler_positions.clear();
4009        self.indexed_log_handlers.clear();
4010        self.fallback_log_handlers.clear();
4011        self.data_slice_shapes.clear();
4012
4013        for (position, (_, handler)) in handlers.into_iter().enumerate() {
4014            let position = position as u128;
4015            self.handler_positions.insert(handler.id.clone(), position);
4016            if let Some(index) = &handler.log_route_index {
4017                for key in index.keys() {
4018                    if let LogRouteKey::DataSlice { offset, value } = key {
4019                        *self
4020                            .data_slice_shapes
4021                            .entry((*offset, value.len()))
4022                            .or_default() += 1;
4023                    }
4024                    self.indexed_log_handlers
4025                        .entry(key.clone())
4026                        .or_default()
4027                        .insert(position);
4028                }
4029            } else if handler.has_log_interests {
4030                self.fallback_log_handlers.insert(position);
4031            }
4032            self.handlers.insert(position, handler);
4033        }
4034        self.next_handler_position = self.handlers.len() as u128;
4035    }
4036}
4037
4038impl<N: Network> ReactiveRuntime<N> {
4039    /// Create an empty runtime.
4040    pub fn new(config: ReactiveConfig) -> Self {
4041        Self {
4042            registry: ReactiveRegistry::new(),
4043            hooks: Vec::new(),
4044            config,
4045            journal: VecDeque::new(),
4046            coverage_head: None,
4047            pending_resyncs: Vec::new(),
4048            health: CacheHealth::Healthy,
4049            safe_head: None,
4050            finalized_head: None,
4051            metrics: CacheMetrics::default(),
4052            freshness: None,
4053            tracking: HashMap::new(),
4054            tracked_roots: HashMap::new(),
4055            root_gate_cadence: RootGateCadence::default(),
4056            last_gate_block: None,
4057            touched_since_gate: HashSet::new(),
4058            preconfirmed_branch: None,
4059        }
4060    }
4061
4062    fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
4063        ReactiveRuntimeState {
4064            journal: self.journal.clone(),
4065            coverage_head: self.coverage_head,
4066            pending_resyncs: self.pending_resyncs.clone(),
4067            health: self.health,
4068            safe_head: self.safe_head,
4069            finalized_head: self.finalized_head,
4070            freshness: self.freshness.clone(),
4071            tracking: self.tracking.clone(),
4072            tracked_roots: self.tracked_roots.clone(),
4073            root_gate_cadence: self.root_gate_cadence,
4074            last_gate_block: self.last_gate_block,
4075            touched_since_gate: self.touched_since_gate.clone(),
4076            metrics: self.metrics.snapshot(),
4077        }
4078    }
4079
4080    fn is_pristine_for_checkpoint_restore(&self) -> bool {
4081        self.preconfirmed_branch.is_none()
4082            && self.journal.is_empty()
4083            && self.coverage_head.is_none()
4084            && self.pending_resyncs.is_empty()
4085            && self.health == CacheHealth::Healthy
4086            && self.safe_head.is_none()
4087            && self.finalized_head.is_none()
4088            && self.tracked_roots.is_empty()
4089            && self.last_gate_block.is_none()
4090            && self.touched_since_gate.is_empty()
4091            && self.metrics.snapshot() == CacheMetricsSnapshot::default()
4092    }
4093
4094    fn adopted_baseline_only(&self) -> Option<BlockRef> {
4095        let baseline = self.coverage_head?;
4096        let journal_is_baseline_only = if self.config.journal_depth == 0 {
4097            self.journal.is_empty()
4098        } else {
4099            self.journal.len() == 1
4100                && self.journal.front().is_some_and(|entry| {
4101                    entry.block == baseline
4102                        && entry.inputs.is_empty()
4103                        && entry.applied.is_empty()
4104                        && entry.handler_ids.is_empty()
4105                        && entry.resynced.is_empty()
4106                        && entry.rollback_diffs.is_empty()
4107                })
4108        };
4109        (self.preconfirmed_branch.is_none()
4110            && journal_is_baseline_only
4111            && self.pending_resyncs.is_empty()
4112            && self.health == CacheHealth::Healthy
4113            && self.safe_head.is_none()
4114            && self.finalized_head.is_none()
4115            && self.tracked_roots.is_empty()
4116            && self.last_gate_block.is_none()
4117            && self.touched_since_gate.is_empty()
4118            && self.metrics.snapshot() == CacheMetricsSnapshot::default())
4119        .then_some(baseline)
4120    }
4121
4122    fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
4123        self.journal = state.journal;
4124        self.coverage_head = state.coverage_head;
4125        self.pending_resyncs = state.pending_resyncs;
4126        self.health = state.health;
4127        self.safe_head = state.safe_head;
4128        self.finalized_head = state.finalized_head;
4129        self.freshness = state.freshness;
4130        self.tracking = state.tracking;
4131        self.tracked_roots = state.tracked_roots;
4132        self.root_gate_cadence = state.root_gate_cadence;
4133        self.last_gate_block = state.last_gate_block;
4134        self.touched_since_gate = state.touched_since_gate;
4135        self.metrics.restore(state.metrics);
4136    }
4137
4138    fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
4139        // Metrics describe lifetime observations, including rejected attempts,
4140        // and are documented as monotonic. Roll back canonical/runtime state
4141        // without erasing the failure signal that caused the transaction to
4142        // abort.
4143        let metrics = self.metrics.snapshot();
4144        self.restore_state(state);
4145        self.metrics.restore(metrics);
4146    }
4147
4148    fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
4149        let checkpoint = DurableRuntimeCheckpoint {
4150            version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
4151            safe_head: self.safe_head,
4152            finalized_head: self.finalized_head,
4153            health: self.health,
4154            pending_resyncs: self.pending_resyncs.clone(),
4155            coverage_head: self.coverage_head,
4156            journal: self
4157                .journal
4158                .iter()
4159                .map(|entry| DurableBlockJournal {
4160                    block: entry.block,
4161                    handler_ids: entry.handler_ids.clone(),
4162                    rollback_diffs: entry.rollback_diffs.clone(),
4163                })
4164                .collect(),
4165            freshness: self.freshness.clone(),
4166            tracking: self.tracking.clone(),
4167            tracked_roots: self.tracked_roots.clone(),
4168            root_gate_cadence: self.root_gate_cadence,
4169            last_gate_block: self.last_gate_block,
4170            touched_since_gate: self.touched_since_gate.clone(),
4171            metrics: self.metrics.snapshot(),
4172        };
4173        bincode::serialize(&checkpoint)
4174            .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
4175    }
4176
4177    fn plan_durable_checkpoint_restore(
4178        &self,
4179        bytes: &[u8],
4180        expected_coverage: &BlockRef,
4181    ) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
4182        let mut cursor = std::io::Cursor::new(bytes);
4183        let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
4184            .with_fixint_encoding()
4185            .with_limit(bytes.len() as u64)
4186            .deserialize_from(&mut cursor)
4187            .map_err(|error| {
4188                ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
4189            })?;
4190        if cursor.position() != bytes.len() as u64 {
4191            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4192                "runtime checkpoint has trailing bytes".to_owned(),
4193            ));
4194        }
4195        if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
4196            return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
4197                format!(
4198                    "unsupported runtime checkpoint version {}",
4199                    checkpoint.version
4200                ),
4201            ));
4202        }
4203        self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
4204
4205        let retained = self.config.journal_depth.min(checkpoint.journal.len());
4206        let discard = checkpoint.journal.len() - retained;
4207        checkpoint.journal.drain(..discard);
4208        Ok(DurableRuntimeRestorePlan {
4209            checkpoint: Some(checkpoint),
4210            fallback_history: Vec::new(),
4211        })
4212    }
4213
4214    fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
4215        let Some(checkpoint) = plan.checkpoint else {
4216            self.journal = plan
4217                .fallback_history
4218                .into_iter()
4219                .map(|block| BlockJournal {
4220                    block,
4221                    inputs: Vec::new(),
4222                    applied: Vec::new(),
4223                    handler_ids: Vec::new(),
4224                    resynced: Vec::new(),
4225                    rollback_diffs: Vec::new(),
4226                })
4227                .collect();
4228            return;
4229        };
4230        self.safe_head = checkpoint.safe_head;
4231        self.finalized_head = checkpoint.finalized_head;
4232        self.health = checkpoint.health;
4233        self.pending_resyncs = checkpoint.pending_resyncs;
4234        self.coverage_head = checkpoint.coverage_head;
4235        self.journal = checkpoint
4236            .journal
4237            .into_iter()
4238            .map(|entry| BlockJournal {
4239                block: entry.block,
4240                inputs: Vec::new(),
4241                applied: Vec::new(),
4242                handler_ids: entry.handler_ids,
4243                resynced: Vec::new(),
4244                rollback_diffs: entry.rollback_diffs,
4245            })
4246            .collect();
4247        self.freshness = checkpoint.freshness;
4248        self.tracking = checkpoint.tracking;
4249        self.tracked_roots = checkpoint.tracked_roots;
4250        self.root_gate_cadence = checkpoint.root_gate_cadence;
4251        self.last_gate_block = checkpoint.last_gate_block;
4252        self.touched_since_gate = checkpoint.touched_since_gate;
4253        self.metrics.restore(checkpoint.metrics);
4254    }
4255
4256    fn validate_durable_runtime_checkpoint(
4257        &self,
4258        checkpoint: &DurableRuntimeCheckpoint,
4259        expected_coverage: &BlockRef,
4260    ) -> Result<(), ReactiveCheckpointRestoreError> {
4261        let invalid =
4262            |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
4263        let Some(coverage) = checkpoint.coverage_head.as_ref() else {
4264            return Err(invalid(
4265                "runtime checkpoint is missing its canonical coverage head".into(),
4266            ));
4267        };
4268        if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
4269            return Err(invalid(format!(
4270                "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
4271                coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
4272            )));
4273        }
4274        for (label, head) in [
4275            ("safe", checkpoint.safe_head.as_ref()),
4276            ("finalized", checkpoint.finalized_head.as_ref()),
4277        ] {
4278            let Some(head) = head else { continue };
4279            if head.number > coverage.number
4280                || (head.number == coverage.number && head.hash != coverage.hash)
4281            {
4282                return Err(invalid(format!(
4283                    "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
4284                    head.number, head.hash, coverage.number, coverage.hash
4285                )));
4286            }
4287            if head.number.checked_add(1) == Some(coverage.number)
4288                && coverage
4289                    .parent_hash
4290                    .is_some_and(|parent| parent != head.hash)
4291            {
4292                return Err(invalid(format!(
4293                    "canonical coverage does not descend from adjacent {label} head"
4294                )));
4295            }
4296        }
4297        if let (Some(finalized), Some(safe)) = (
4298            checkpoint.finalized_head.as_ref(),
4299            checkpoint.safe_head.as_ref(),
4300        ) {
4301            if finalized.number > safe.number
4302                || (finalized.number == safe.number && finalized.hash != safe.hash)
4303            {
4304                return Err(invalid(
4305                    "finalized head is above or conflicts with the safe head".into(),
4306                ));
4307            }
4308            if finalized.number.checked_add(1) == Some(safe.number)
4309                && safe.parent_hash != Some(finalized.hash)
4310            {
4311                return Err(invalid(
4312                    "adjacent safe head does not descend from finalized head".into(),
4313                ));
4314            }
4315        }
4316
4317        let mut previous: Option<&DurableBlockJournal> = None;
4318        for entry in &checkpoint.journal {
4319            if entry.block.number > coverage.number
4320                || (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
4321            {
4322                return Err(invalid(format!(
4323                    "journal block {}:{:?} lies beyond or conflicts with canonical coverage",
4324                    entry.block.number, entry.block.hash
4325                )));
4326            }
4327            if let Some(previous) = previous {
4328                if entry.block.number <= previous.block.number {
4329                    return Err(invalid(
4330                        "runtime journal block numbers are not strictly increasing".into(),
4331                    ));
4332                }
4333                if previous.block.number.checked_add(1) == Some(entry.block.number)
4334                    && entry.block.parent_hash.is_some()
4335                    && entry.block.parent_hash != Some(previous.block.hash)
4336                {
4337                    return Err(invalid(
4338                        "adjacent runtime journal blocks are not parent-linked".into(),
4339                    ));
4340                }
4341            }
4342            for (label, head) in [
4343                ("safe", checkpoint.safe_head.as_ref()),
4344                ("finalized", checkpoint.finalized_head.as_ref()),
4345            ] {
4346                if let Some(head) = head
4347                    && head.number == entry.block.number
4348                    && !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
4349                {
4350                    return Err(invalid(format!(
4351                        "{label} head conflicts with the retained journal at block {}",
4352                        head.number
4353                    )));
4354                }
4355            }
4356            let mut handler_ids = HashSet::new();
4357            if entry
4358                .handler_ids
4359                .iter()
4360                .any(|handler_id| !handler_ids.insert(handler_id))
4361            {
4362                return Err(invalid(
4363                    "runtime journal contains duplicate handler generation ids".into(),
4364                ));
4365            }
4366            previous = Some(entry);
4367        }
4368        if let Some(tail) = checkpoint.journal.last()
4369            && tail.block.number == coverage.number
4370            && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
4371        {
4372            return Err(invalid(format!(
4373                "runtime journal tail conflicts with canonical coverage at block {}",
4374                coverage.number
4375            )));
4376        }
4377        if let Some(tail) = checkpoint.journal.last()
4378            && tail.block.number.checked_add(1) == Some(coverage.number)
4379            && coverage
4380                .parent_hash
4381                .is_some_and(|parent_hash| parent_hash != tail.block.hash)
4382        {
4383            return Err(invalid(format!(
4384                "canonical coverage does not descend from adjacent runtime journal tail at block {}",
4385                tail.block.number
4386            )));
4387        }
4388
4389        if let Some(last_gate_block) = checkpoint.last_gate_block {
4390            if last_gate_block > coverage.number {
4391                return Err(invalid(
4392                    "root-gate cursor lies beyond canonical coverage".into(),
4393                ));
4394            }
4395        } else if !checkpoint.tracked_roots.is_empty() {
4396            return Err(invalid(
4397                "root-gate baselines exist without a completed gate cursor".into(),
4398            ));
4399        }
4400        for (address, baseline) in &checkpoint.tracked_roots {
4401            let Some(policy) = checkpoint.tracking.get(address) else {
4402                return Err(invalid(
4403                    "root-gate baseline has no corresponding tracking policy".into(),
4404                ));
4405            };
4406            if matches!(policy, TrackingPolicy::Slots { .. }) {
4407                return Err(invalid(
4408                    "slot-only tracking cannot carry an account root baseline".into(),
4409                ));
4410            }
4411            if baseline.last_block > coverage.number
4412                || checkpoint
4413                    .last_gate_block
4414                    .is_some_and(|last_gate| baseline.last_block > last_gate)
4415            {
4416                return Err(invalid(
4417                    "root-gate baseline lies beyond the committed gate window".into(),
4418                ));
4419            }
4420        }
4421        Ok(())
4422    }
4423
4424    /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4).
4425    ///
4426    /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the
4427    /// gate as a no-op. Registering an account clears any baseline it held (a
4428    /// policy change re-adopts on the next probe rather than diffing against a
4429    /// baseline captured under the old policy). Each [`RootGateCadence`]
4430    /// firing, the gate
4431    /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and
4432    /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the
4433    /// account-proof seam and, on a move no decoder covered, emits a
4434    /// [`ReactiveReport::CoverageGap`] and schedules a
4435    /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots)
4436    /// accounts are never root-gated (spec Decision 3).
4437    pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
4438        self.tracking.insert(address, policy);
4439        self.tracked_roots.remove(&address);
4440    }
4441
4442    /// Stop tracking `address`, dropping its policy and any adopted baseline.
4443    ///
4444    /// Returns `true` if the account was tracked.
4445    pub fn untrack_account(&mut self, address: Address) -> bool {
4446        self.tracked_roots.remove(&address);
4447        self.tracking.remove(&address).is_some()
4448    }
4449
4450    /// Set how often the root gate probes tracked accounts (default:
4451    /// [`RootGateCadence::default`] — every 16 canonical blocks; see the
4452    /// [`RootGateCadence`] docs for why skipping blocks loses no detection).
4453    ///
4454    /// Reconfiguring resets the gate's window bookkeeping (the touched-address
4455    /// accumulator and the last-fired block), so a stale window never leaks
4456    /// into the new cadence: the next canonical block fires the gate.
4457    pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
4458        self.root_gate_cadence = cadence;
4459        self.last_gate_block = None;
4460        self.touched_since_gate.clear();
4461    }
4462
4463    /// The configured [`RootGateCadence`].
4464    pub fn root_gate_cadence(&self) -> RootGateCadence {
4465        self.root_gate_cadence
4466    }
4467
4468    /// Enable freshness stamping of canonical event-derived writes (opt-in).
4469    ///
4470    /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present,
4471    /// applying a canonical handler storage-slot effect for a block `N` stamps the
4472    /// touched `(address, slot)` as
4473    /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`.
4474    /// The slot is therefore not volatile *at* `N` (event-maintained, no need to
4475    /// re-verify) but ages to volatile once the clock passes `N`.
4476    ///
4477    /// Idempotent: if a registry is already installed it is left untouched, so an
4478    /// existing registry (and any stamps it holds) is never clobbered.
4479    pub fn enable_freshness_stamping(&mut self) {
4480        if self.freshness.is_none() {
4481            self.freshness = Some(FreshnessRegistry::new());
4482        }
4483    }
4484
4485    /// Borrow the runtime's freshness registry, if stamping was enabled.
4486    ///
4487    /// Returns `None` unless
4488    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4489    pub fn freshness(&self) -> Option<&FreshnessRegistry> {
4490        self.freshness.as_ref()
4491    }
4492
4493    /// Mutably borrow the runtime's freshness registry, if stamping was enabled.
4494    ///
4495    /// Returns `None` unless
4496    /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called.
4497    pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
4498        self.freshness.as_mut()
4499    }
4500
4501    /// Return the current queryable [`CacheHealth`] of the runtime.
4502    pub fn health(&self) -> CacheHealth {
4503        self.health
4504    }
4505
4506    /// Return a point-in-time snapshot of the runtime's observability counters.
4507    pub fn metrics(&self) -> CacheMetricsSnapshot {
4508        self.metrics.snapshot()
4509    }
4510
4511    /// Complete the caller-driven self-heal by returning health to
4512    /// [`CacheHealth::Healthy`].
4513    ///
4514    /// A trust-loss event (a reorg deeper than the journal, or a detected missed
4515    /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a
4516    /// "stop until rebuilt" signal that the caller must act on. Once the caller
4517    /// has resynced or rebuilt the affected state, it invokes this to clear the
4518    /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is
4519    /// called outside an ingest cycle.
4520    pub fn reset_health(&mut self) {
4521        self.health = CacheHealth::Healthy;
4522    }
4523
4524    /// Escalate health one rung up the trust-loss ladder for a trust-loss event
4525    /// observed at `block`, returning a [`ReactiveReport::Health`] report when the
4526    /// state actually changes.
4527    ///
4528    /// The ladder is:
4529    /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded)
4530    /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy)
4531    /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`)
4532    ///
4533    /// A first event degrades; a second escalates to the terminal
4534    /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both
4535    /// trust-loss paths (deep reorg beyond the journal and missed-range
4536    /// detection) so mixed event types climb the same ladder.
4537    fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
4538        let to = match self.health {
4539            CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
4540            CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
4541            CacheHealth::Unhealthy { .. } => return None,
4542        };
4543        self.transition_health(to, Some(block))
4544    }
4545
4546    /// Transition health to `to`, returning a [`ReactiveReport::Health`] report
4547    /// when the state actually changes.
4548    ///
4549    /// The returned report must be threaded into the ingest cycle's dispatched
4550    /// reports so it reaches hooks and appears in
4551    /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the
4552    /// current state (no transition, no report).
4553    fn transition_health(
4554        &mut self,
4555        to: CacheHealth,
4556        block: Option<u64>,
4557    ) -> Option<Arc<ReactiveReport<N>>> {
4558        if to == self.health {
4559            return None;
4560        }
4561        let from = self.health;
4562        self.health = to;
4563        Some(Arc::new(ReactiveReport::Health(HealthReport {
4564            from,
4565            to,
4566            block,
4567            _network: PhantomData,
4568        })))
4569    }
4570
4571    /// Register a handler.
4572    ///
4573    /// # Errors
4574    ///
4575    /// Returns [`RegisterError::DuplicateHandler`] when the id is already
4576    /// registered.
4577    pub fn register_handler(
4578        &mut self,
4579        handler: Arc<dyn ReactiveHandler<N>>,
4580    ) -> Result<(), RegisterError> {
4581        self.registry.register_handler(handler)
4582    }
4583
4584    /// Remove one handler from the runtime registry without resetting runtime state.
4585    ///
4586    /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does
4587    /// not clear the reorg journal, health, metrics, hooks, pending resyncs,
4588    /// tracking policy, freshness registry, or root-gate baselines, and it does
4589    /// not purge [`EvmCache`] state. Callers that want cache eviction must issue
4590    /// explicit `StateUpdate::purge` updates or use cache purge APIs separately.
4591    pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
4592        self.registry.unregister_handler(id)
4593    }
4594
4595    /// Return true when the runtime has a registered handler with `id`.
4596    pub fn contains_handler(&self, id: &HandlerId) -> bool {
4597        self.registry.contains_handler(id)
4598    }
4599
4600    /// Ids of all registered handlers, in registration (= routing) order.
4601    pub fn handler_ids(&self) -> Vec<HandlerId> {
4602        self.registry.handler_ids()
4603    }
4604
4605    /// Borrow the interests owned by one registered handler.
4606    pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
4607        self.registry.handler_interests(id)
4608    }
4609
4610    /// The most recently journaled canonical block, if any.
4611    ///
4612    /// This is the runtime's current chain position: the canonical block most
4613    /// recently recorded by ingestion. Reorged blocks are dropped from the
4614    /// journal during recovery, so a rolled-back head does not linger here.
4615    /// [`ReactiveEngine::register_handler`] uses it as the default backfill
4616    /// anchor for handlers registered mid-lifecycle. An ordered barrier may
4617    /// advance this coverage position across an empty event range. `None` until
4618    /// the first canonical input or barrier is accepted.
4619    pub fn last_canonical_block(&self) -> Option<BlockRef> {
4620        self.coverage_head
4621    }
4622
4623    /// Adopt an exact RPC snapshot block as this runtime's canonical starting
4624    /// position without applying effects or dispatching reports.
4625    ///
4626    /// Handlers, hooks, tracking policy, and freshness configuration may be
4627    /// installed before adoption, but no chain input, finality, resync,
4628    /// root-gate observation, or health transition may have occurred. An exact
4629    /// repeat is idempotent; a different repeat and any active runtime fail
4630    /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache
4631    /// and subscriber are available so chain identity and the cache's exact
4632    /// hash pin are validated too.
4633    ///
4634    /// # Errors
4635    ///
4636    /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime
4637    /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a
4638    /// different baseline has already been adopted.
4639    pub fn adopt_canonical_baseline(
4640        &mut self,
4641        baseline: BlockRef,
4642    ) -> Result<(), ReactiveBaselineError> {
4643        self.validate_canonical_baseline_adoption(baseline)?;
4644        if self.adopted_baseline_only().is_some() {
4645            return Ok(());
4646        }
4647
4648        self.coverage_head = Some(baseline);
4649        if self.config.journal_depth > 0 {
4650            self.journal.push_back(BlockJournal {
4651                block: baseline,
4652                inputs: Vec::new(),
4653                applied: Vec::new(),
4654                handler_ids: Vec::new(),
4655                resynced: Vec::new(),
4656                rollback_diffs: Vec::new(),
4657            });
4658        }
4659        Ok(())
4660    }
4661
4662    fn validate_canonical_baseline_adoption(
4663        &self,
4664        baseline: BlockRef,
4665    ) -> Result<(), ReactiveBaselineError> {
4666        if let Some(existing) = self.adopted_baseline_only() {
4667            return if existing == baseline {
4668                Ok(())
4669            } else {
4670                Err(ReactiveBaselineError::ConflictingBaseline {
4671                    existing_number: existing.number,
4672                    existing_hash: existing.hash,
4673                    requested_number: baseline.number,
4674                    requested_hash: baseline.hash,
4675                })
4676            };
4677        }
4678        if !self.is_pristine_for_checkpoint_restore() {
4679            return Err(ReactiveBaselineError::ActiveRuntime);
4680        }
4681        Ok(())
4682    }
4683
4684    /// Most recent safe head explicitly reported by the event source.
4685    pub const fn safe_head(&self) -> Option<&BlockRef> {
4686        self.safe_head.as_ref()
4687    }
4688
4689    /// Most recent finalized head explicitly reported by the event source.
4690    pub const fn finalized_head(&self) -> Option<&BlockRef> {
4691        self.finalized_head.as_ref()
4692    }
4693
4694    /// Return whether the retained reorg journal still contains an applied
4695    /// record for `handler_id`.
4696    ///
4697    /// The record is retained even when the handler emitted only resync work,
4698    /// so an owner can keep an explicit cache-eviction fence active for exactly
4699    /// as long as a later rollback could restore effects from that handler
4700    /// generation. This query is bounded by [`ReactiveConfig::journal_depth`].
4701    pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
4702        self.journal
4703            .iter()
4704            .any(|entry| entry.handler_ids.contains(handler_id))
4705    }
4706
4707    /// Return the distinct handler generations represented in the retained
4708    /// reorg journal.
4709    ///
4710    /// This scans the bounded journal once, allowing a lifecycle owner to age a
4711    /// large set of cache-eviction fences without rescanning the journal for
4712    /// every handler.
4713    pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
4714        self.journal
4715            .iter()
4716            .flat_map(|entry| entry.handler_ids.iter().cloned())
4717            .collect()
4718    }
4719
4720    /// Queued resync requests: surfaced by handlers but not yet executed by an
4721    /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass.
4722    ///
4723    /// Callers driving resync execution themselves (plain
4724    /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here;
4725    /// reorg recovery cancels entries whose pinned blocks were dropped, and
4726    /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact
4727    /// generation-owned work, while
4728    /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries
4729    /// for exclusively torn-down accounts.
4730    pub fn pending_resyncs(&self) -> &[ResyncRequest] {
4731        &self.pending_resyncs
4732    }
4733
4734    /// Cancel every queued request with the exact logical `id`.
4735    ///
4736    /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this
4737    /// removes whole requests and never touches other work merely because it
4738    /// targets the same account. It is therefore the safe primitive for
4739    /// generation-scoped owner teardown when the caller maintains an
4740    /// owner-to-[`ResyncId`] index. Requests already returned to the caller in
4741    /// an earlier batch report cannot be recalled.
4742    pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
4743        self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
4744    }
4745
4746    /// Cancel queued requests whose logical ids occur in `ids` in one queue pass.
4747    ///
4748    /// Duplicate and unknown ids are harmless. Cancelled requests retain their
4749    /// pending-queue order, independent of caller id order. This is the batch
4750    /// teardown primitive for owners that can have many pending repairs; it
4751    /// avoids rescanning the complete pending queue once per owned id.
4752    pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
4753        if ids.is_empty() {
4754            return Vec::new();
4755        }
4756        let ids: HashSet<&ResyncId> = ids.iter().collect();
4757        let mut cancelled = Vec::new();
4758        self.pending_resyncs.retain(|request| {
4759            if ids.contains(&request.id) {
4760                cancelled.push(request.clone());
4761                false
4762            } else {
4763                true
4764            }
4765        });
4766        cancelled
4767    }
4768
4769    /// Cancel queued resync work that targets `address`, returning the
4770    /// cancelled portions.
4771    ///
4772    /// Every pending [`ResyncRequest`] target referencing `address` is removed;
4773    /// a request reduced to zero targets is dropped entirely, while
4774    /// mixed-target requests keep their other accounts queued. Each returned
4775    /// request mirrors the original id/reason/block/priority and carries only
4776    /// the targets that were cancelled.
4777    ///
4778    /// This is appropriate only when the caller owns the complete account. For
4779    /// a pool sharing a vault or emitter with other owners, cancel its exact
4780    /// request IDs through
4781    /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot
4782    /// recall requests already returned to the caller in earlier batch reports.
4783    pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
4784        let mut cancelled = Vec::new();
4785        self.pending_resyncs.retain_mut(|request| {
4786            let (matching, remaining): (Vec<_>, Vec<_>) = request
4787                .targets
4788                .drain(..)
4789                .partition(|target| resync_target_address(target) == address);
4790            request.targets = remaining;
4791            if !matching.is_empty() {
4792                cancelled.push(ResyncRequest {
4793                    id: request.id.clone(),
4794                    reason: request.reason.clone(),
4795                    block: request.block.clone(),
4796                    targets: matching,
4797                    priority: request.priority,
4798                });
4799            }
4800            !request.targets.is_empty()
4801        });
4802        cancelled
4803    }
4804
4805    /// Register a hook.
4806    ///
4807    /// # Errors
4808    ///
4809    /// This implementation is currently infallible; the `Result` preserves the
4810    /// registration contract for future hook validation.
4811    pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
4812        self.hooks.push(hook);
4813        Ok(())
4814    }
4815
4816    /// Return all registered interests in handler registration order.
4817    pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
4818        self.registry.interests()
4819    }
4820
4821    /// Ingest a batch, apply valid direct state effects, and dispatch reports.
4822    ///
4823    /// The commit is atomic on `Err`: cache state and canonical runtime state are
4824    /// restored before the error returns, and hooks see no reports. Monotonic
4825    /// observability counters still retain rejected-attempt signals.
4826    /// The current rollback guard snapshots complete mutable cache state once per
4827    /// batch, so callers should preserve transport batching rather than splitting
4828    /// one delivery into many one-record calls.
4829    ///
4830    /// # Errors
4831    ///
4832    /// Returns [`ReactiveError`] when records or controls are invalid, canonical
4833    /// continuity cannot be proven, a handler rejects input, or an effect cannot
4834    /// be applied. A pre-confirmed batch additionally requires an adopted
4835    /// canonical coverage head and must identify its exact child by number and
4836    /// parent hash. Cache and canonical runtime state are restored before
4837    /// return; a lineage failure revokes any active speculative branch.
4838    pub fn ingest_batch(
4839        &mut self,
4840        cache: &mut EvmCache,
4841        batch: ReactiveInputBatch<N>,
4842    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4843        let preconfirmation = batch_preconfirmation(&batch)?;
4844        if let Some(flashblock) = preconfirmation.as_ref() {
4845            self.prepare_preconfirmed_branch(cache, flashblock)?;
4846        } else {
4847            self.discard_preconfirmed_branch(cache);
4848        }
4849        let cache_state = EvmCacheStateSnapshot::capture(cache);
4850        let runtime_state = self.checkpoint_state();
4851        let batch_report = match self.ingest_batch_direct(cache, batch) {
4852            Ok(report) => report,
4853            Err(error) => {
4854                cache_state.restore(cache);
4855                self.restore_transaction_state(runtime_state);
4856                return Err(error);
4857            }
4858        };
4859        if let Some(flashblock) = preconfirmation {
4860            self.restore_transaction_state(runtime_state);
4861            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4862                branch.flashblock = flashblock;
4863            }
4864        }
4865        self.dispatch_reports(&batch_report.reports);
4866        let _ = &self.config;
4867        Ok(batch_report)
4868    }
4869
4870    /// Ingest a batch, then execute surfaced storage resync requests.
4871    ///
4872    /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for
4873    /// direct handler effects, then runs a synchronous resync phase over the
4874    /// collected [`ResyncRequest`]s. Storage targets are fetched through
4875    /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful
4876    /// values are applied as [`StateUpdate::slot`] updates through
4877    /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported
4878    /// in [`ResyncReport::failed`]. It does not start subscribers, background
4879    /// workers, or network transport.
4880    ///
4881    /// # Errors
4882    ///
4883    /// Returns [`ReactiveError`] for the same validation, continuity, handler,
4884    /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed
4885    /// resync targets are reported in the successful batch report instead.
4886    pub fn ingest_batch_with_resync(
4887        &mut self,
4888        cache: &mut EvmCache,
4889        batch: ReactiveInputBatch<N>,
4890    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
4891        let preconfirmation = batch_preconfirmation(&batch)?;
4892        if let Some(flashblock) = preconfirmation.as_ref() {
4893            self.prepare_preconfirmed_branch(cache, flashblock)?;
4894        } else {
4895            self.discard_preconfirmed_branch(cache);
4896        }
4897        let cache_state = EvmCacheStateSnapshot::capture(cache);
4898        let runtime_state = self.checkpoint_state();
4899        let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
4900            Ok(report) => report,
4901            Err(error) => {
4902                cache_state.restore(cache);
4903                self.restore_transaction_state(runtime_state);
4904                return Err(error);
4905            }
4906        };
4907
4908        if let Some(flashblock) = preconfirmation {
4909            self.restore_transaction_state(runtime_state);
4910            if let Some(branch) = self.preconfirmed_branch.as_mut() {
4911                branch.flashblock = flashblock;
4912            }
4913        }
4914
4915        self.dispatch_reports(&batch_report.reports);
4916        let _ = &self.config;
4917        Ok(batch_report)
4918    }
4919
4920    /// Active speculative Flashblock snapshot, when the cache currently
4921    /// includes pre-confirmed effects.
4922    pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
4923        self.preconfirmed_branch
4924            .as_ref()
4925            .map(|branch| &branch.flashblock)
4926    }
4927
4928    /// Restore the cache to its canonical state and discard any speculative
4929    /// Flashblock effects.
4930    pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
4931        self.discard_preconfirmed_branch(cache);
4932    }
4933
4934    fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
4935        if let Some(branch) = self.preconfirmed_branch.take() {
4936            branch.canonical_cache.restore(cache);
4937        }
4938    }
4939
4940    fn prepare_preconfirmed_branch(
4941        &mut self,
4942        cache: &mut EvmCache,
4943        incoming: &FlashblockRef,
4944    ) -> Result<(), ReactiveError> {
4945        let Some(canonical) = self.coverage_head else {
4946            self.discard_preconfirmed_branch(cache);
4947            return Err(ReactiveError::InvalidInputRecord {
4948                message: "pre-confirmed state requires an exact canonical coverage baseline".into(),
4949            });
4950        };
4951        if canonical.number.checked_add(1) != Some(incoming.block_number) {
4952            self.discard_preconfirmed_branch(cache);
4953            return Err(ReactiveError::InvalidInputRecord {
4954                message: format!(
4955                    "pre-confirmed block {} is not the exact successor of canonical block {}",
4956                    incoming.block_number, canonical.number
4957                ),
4958            });
4959        }
4960        if incoming.parent_hash != Some(canonical.hash) {
4961            self.discard_preconfirmed_branch(cache);
4962            return Err(ReactiveError::InvalidInputRecord {
4963                message: "pre-confirmed block parent does not match the canonical coverage hash"
4964                    .into(),
4965            });
4966        }
4967        if let Some(active) = self.preconfirmed_branch.as_ref()
4968            && active.flashblock.same_payload(incoming)
4969        {
4970            if let (Some(active_index), Some(incoming_index)) =
4971                (active.flashblock.index, incoming.index)
4972                && incoming_index < active_index
4973            {
4974                return Err(ReactiveError::InvalidInputRecord {
4975                    message: format!(
4976                        "Flashblock index regressed from {active_index} to {incoming_index}"
4977                    ),
4978                });
4979            }
4980            if active.flashblock.index.is_some()
4981                && active.flashblock.index == incoming.index
4982                && active.flashblock.content_hash != incoming.content_hash
4983            {
4984                self.discard_preconfirmed_branch(cache);
4985                return Err(ReactiveError::InvalidInputRecord {
4986                    message: "same Flashblock payload/index carried conflicting cumulative content"
4987                        .into(),
4988                });
4989            }
4990            install_preconfirmed_cache_context(cache, incoming);
4991            return Ok(());
4992        }
4993
4994        self.discard_preconfirmed_branch(cache);
4995        self.preconfirmed_branch = Some(PreconfirmedBranch {
4996            flashblock: incoming.clone(),
4997            canonical_cache: EvmCacheStateSnapshot::capture(cache),
4998        });
4999        install_preconfirmed_cache_context(cache, incoming);
5000        Ok(())
5001    }
5002
5003    fn ingest_batch_with_resync_direct(
5004        &mut self,
5005        cache: &mut EvmCache,
5006        batch: ReactiveInputBatch<N>,
5007    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5008        let mut batch_report = self.ingest_batch_direct(cache, batch)?;
5009        if !batch_report.resyncs.is_empty() {
5010            let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
5011            // Count unique logical requests: several handlers may emit the same
5012            // ResyncId in one batch, and duplicates fan out per-origin in the
5013            // report but are one unit of resync work for the metric.
5014            let unique_requests = resync_report
5015                .requested
5016                .iter()
5017                .map(|request| &request.id)
5018                .collect::<HashSet<_>>()
5019                .len();
5020            self.metrics
5021                .resync_requests
5022                .fetch_add(unique_requests as u64, Ordering::Relaxed);
5023            self.metrics
5024                .resync_failures
5025                .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
5026            self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
5027            self.record_journal_resync(&resync_report);
5028            batch_report
5029                .reports
5030                .push(Arc::new(ReactiveReport::Resynced(resync_report)));
5031        }
5032        Ok(batch_report)
5033    }
5034
5035    fn ingest_batch_direct(
5036        &mut self,
5037        cache: &mut EvmCache,
5038        batch: ReactiveInputBatch<N>,
5039    ) -> Result<ReactiveBatchReport<N>, ReactiveError> {
5040        let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
5041        if let Some(chain_id) = batch_chain_id
5042            && chain_id != cache.chain_id()
5043        {
5044            return Err(ReactiveError::InvalidInputRecord {
5045                message: format!(
5046                    "batch chain id {chain_id} does not match cache chain id {}",
5047                    cache.chain_id()
5048                ),
5049            });
5050        }
5051        if !chain_controls.is_empty() && batch_chain_id.is_none() {
5052            return Err(ReactiveError::InvalidChainControl {
5053                message: "chain-control batches require an authoritative batch chain id".into(),
5054            });
5055        }
5056        for (record, _, _) in &records {
5057            record.validated_identity()?;
5058            if let Some(chain_id) = record.context.chain_id
5059                && chain_id != cache.chain_id()
5060            {
5061                return Err(ReactiveError::InvalidInputRecord {
5062                    message: format!(
5063                        "input chain id {chain_id} does not match cache chain id {}",
5064                        cache.chain_id()
5065                    ),
5066                });
5067            }
5068        }
5069        let records = sort_scoped_records(dedupe_scoped_records(records)?);
5070
5071        let mut batch_report = ReactiveBatchReport::default();
5072        let mut reports_to_dispatch = Vec::new();
5073        let control_split = validate_control_phase_order(&chain_controls)?;
5074        let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
5075        let pre_record_state =
5076            self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
5077        self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
5078        let mut batch_dropped = BatchDroppedCanonical::default();
5079        for control in pre_record_controls {
5080            if let ChainControl::Reorg {
5081                common_ancestor,
5082                old_tip,
5083                ..
5084            } = control
5085            {
5086                batch_dropped.record_explicit(common_ancestor, old_tip);
5087                let drained = self
5088                    .journal
5089                    .iter()
5090                    .filter(|entry| entry.block.number > common_ancestor.number)
5091                    .map(|entry| entry.block)
5092                    .collect::<Vec<_>>();
5093                batch_dropped.record_drained(&drained);
5094            }
5095        }
5096        let certified_progress_through = post_record_controls
5097            .iter()
5098            .filter_map(canonical_coverage_control_block)
5099            .map(|block| block.number)
5100            .max();
5101        for control in pre_record_controls.iter().cloned() {
5102            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5103        }
5104        // Phase-8 step 4: accumulate the addresses a decoder actually wrote this
5105        // batch (union of applied `StateDiff` addresses) and the batch's canonical
5106        // block number, so the per-block root gate can run once after the record
5107        // loop with the full touched set.
5108        let mut touched_addrs: HashSet<Address> = HashSet::new();
5109        let mut canonical_batch_block: Option<u64> = None;
5110
5111        for (record, audience, delivery_scope) in records {
5112            let raw_canonical_block = canonical_record_block(&record).copied();
5113            let canonical_block = raw_canonical_block.map(|block| {
5114                pre_record_state
5115                    .resolved_canonical_blocks
5116                    .get(&(block.number, block.hash))
5117                    .copied()
5118                    .unwrap_or(block)
5119            });
5120            let input_ref = record.input_ref();
5121            reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
5122                input_ref,
5123                context: record.context.clone(),
5124                provider: record.provider.clone(),
5125                _network: PhantomData,
5126            })));
5127
5128            let recovered_reorg = if delivery_scope.advances_canonical_state() {
5129                if let Some(block) = canonical_block.as_ref() {
5130                    let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
5131                        && certified_progress_through
5132                            .is_some_and(|through| block.number <= through);
5133                    let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
5134                        raw.parent_hash.is_none()
5135                            && batch_dropped.covers_implicit_number(raw.number)
5136                    });
5137                    self.recover_for_canonical_input(
5138                        cache,
5139                        block,
5140                        gap_is_certified,
5141                        parentless_replacement_is_proven,
5142                        &mut reports_to_dispatch,
5143                    )
5144                } else {
5145                    None
5146                }
5147            } else {
5148                None
5149            };
5150            let recovered_reorg_for_input = recovered_reorg.is_some();
5151            if let Some(reorg_report) = recovered_reorg {
5152                self.metrics
5153                    .reorgs_recovered
5154                    .fetch_add(1, Ordering::Relaxed);
5155                remove_canceled_resyncs_from_batch(
5156                    &mut batch_report.resyncs,
5157                    &reorg_report.canceled_resyncs,
5158                );
5159                reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5160            }
5161
5162            // Removed/reorged records are lifecycle signals, never handler
5163            // data. Canonical scopes may roll back state; owner-only catch-up
5164            // scopes deliberately cannot, but both must suppress ordinary
5165            // decoding even when the referenced block is unknown, aged out of
5166            // the journal, or has already been removed once.
5167            if reorg_signal_block(&record).is_some() {
5168                if delivery_scope.advances_canonical_state()
5169                    && let Some(reorg_report) = self.recover_for_reorged_input(
5170                        cache,
5171                        &record,
5172                        &mut batch_dropped,
5173                        &mut reports_to_dispatch,
5174                    )
5175                {
5176                    self.metrics
5177                        .reorgs_recovered
5178                        .fetch_add(1, Ordering::Relaxed);
5179                    remove_canceled_resyncs_from_batch(
5180                        &mut batch_report.resyncs,
5181                        &reorg_report.canceled_resyncs,
5182                    );
5183                    reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5184                }
5185                continue;
5186            }
5187
5188            // Preflight validates owner history against the journal state at
5189            // batch entry. A canonical record earlier in this same transaction
5190            // may legitimately replace and drain that block, so close the
5191            // resulting TOCTOU window immediately before any owner handler can
5192            // mutate the cache. The outer transaction guard restores every
5193            // earlier record in the batch on failure.
5194            if delivery_scope == DeliveryScope::OwnerCatchup {
5195                self.validate_owner_catchup_record_against_current_journal(&record)?;
5196            }
5197
5198            if delivery_scope.advances_canonical_state()
5199                && let Some(block) = canonical_block.as_ref()
5200            {
5201                // Phase-8 step 4: remember the batch's canonical block (the last
5202                // canonical record wins) so the root gate probes at that height.
5203                canonical_batch_block = Some(block.number);
5204                self.record_journal_input(block, input_ref);
5205            }
5206
5207            // Keep every lazy provider read pinned to the exact event block
5208            // before handlers run. A full header installs the complete EVM env;
5209            // compact log-only progress installs NUMBER/timestamp and clears
5210            // unknown header-only fields. A later record for the same retained
5211            // canonical block can preserve an already-installed full env.
5212            if delivery_scope.advances_canonical_state()
5213                && let Some(block) = canonical_block.as_ref()
5214            {
5215                match advance_block_for_canonical_record(cache, &record) {
5216                    Some(Ok(())) => {
5217                        cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
5218                    }
5219                    Some(Err(err)) => {
5220                        cache.advance_compact_block(
5221                            block.number,
5222                            block.hash,
5223                            block.timestamp,
5224                            false,
5225                        );
5226                        reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
5227                            ReactiveErrorReport {
5228                                input_ref: Some(input_ref),
5229                                message: err.to_string(),
5230                                _network: PhantomData,
5231                            },
5232                        )));
5233                    }
5234                    None => cache.advance_compact_block(
5235                        block.number,
5236                        block.hash,
5237                        block.timestamp,
5238                        !recovered_reorg_for_input,
5239                    ),
5240                }
5241            }
5242
5243            let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
5244            if executions.is_empty() {
5245                continue;
5246            }
5247
5248            reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
5249                input_ref,
5250                handler_ids: executions
5251                    .iter()
5252                    .map(|execution| execution.handler_id.clone())
5253                    .collect(),
5254                _network: PhantomData,
5255            })));
5256
5257            detect_conflicts(input_ref, &executions)?;
5258
5259            // Phase-8 step 3: canonical block number for freshness stamping.
5260            // Copied out as a plain `u64` (dropping the borrow of `record`) so it
5261            // can be used while `self.freshness_mut()` mutably borrows `self`
5262            // inside the execution loop. `None` for pending/removed/reorged
5263            // records — those never stamp canonical freshness.
5264            let canonical_block_number = delivery_scope
5265                .advances_canonical_state()
5266                .then_some(canonical_block)
5267                .flatten()
5268                .map(|block| block.number);
5269
5270            for execution in executions {
5271                let diff = if execution.state_updates.is_empty() {
5272                    StateDiff::default()
5273                } else {
5274                    cache.apply_updates(&execution.state_updates)
5275                };
5276
5277                batch_report
5278                    .resyncs
5279                    .extend(execution.resyncs.iter().cloned());
5280                self.pending_resyncs
5281                    .extend(execution.resyncs.iter().cloned());
5282                batch_report
5283                    .speculative
5284                    .extend(execution.speculative.iter().cloned());
5285
5286                let applied = AppliedReport {
5287                    input_ref,
5288                    handler_id: execution.handler_id,
5289                    quality: execution.quality,
5290                    tags: execution.tags,
5291                    diff,
5292                    state_updates: execution.state_updates,
5293                    invalidations: execution.invalidations,
5294                    resyncs: execution.resyncs,
5295                    speculative: execution.speculative,
5296                    hook_signals: execution.hook_signals,
5297                    _network: PhantomData,
5298                };
5299                // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)`
5300                // from this canonical handler write as `ValidThrough(N)`, so an
5301                // event-maintained slot stops being re-verified until the clock
5302                // passes its write block. Read the changed slots straight off
5303                // `applied.diff` (which borrows the local, not `self`) and stamp
5304                // via `self.freshness`, done before `applied` is moved into the
5305                // journal/batch below. Only genuinely-changed slots appear here,
5306                // since a no-op re-write records no `SlotChange`.
5307                if let (Some(number), Some(registry)) =
5308                    (canonical_block_number, self.freshness.as_mut())
5309                {
5310                    for change in &applied.diff.slots {
5311                        registry.valid_through_slot(change.address, change.slot, number);
5312                    }
5313                }
5314
5315                // Phase-8 step 4: record every address this decoder actually wrote
5316                // (or attempted to write) so the root gate can tell a
5317                // decoder-covered root move from an uncovered coverage gap. Fold in
5318                // the full `StateDiff` address footprint — real changes
5319                // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so
5320                // a decoder that tried to write a cold slot still counts as
5321                // covering the account.
5322                if delivery_scope.advances_canonical_state() {
5323                    collect_diff_addresses(&applied.diff, &mut touched_addrs);
5324                }
5325
5326                let report = Arc::new(ReactiveReport::Applied(applied.clone()));
5327                reports_to_dispatch.push(report);
5328                if let Some(block) = canonical_block.as_ref() {
5329                    if delivery_scope.advances_canonical_state() {
5330                        self.record_journal_applied(block, applied.clone());
5331                    } else {
5332                        self.record_journal_applied_if_present(block, applied.clone());
5333                    }
5334                }
5335                batch_report.applied.push(applied);
5336            }
5337        }
5338
5339        // Coverage/finality controls certify the records that precede them.
5340        // Applying them here also leaves the live cache pinned to a certified
5341        // zero-event tail rather than the last block that happened to emit a
5342        // matching log. Reorg controls were applied before the record loop.
5343        for control in post_record_controls.iter().cloned() {
5344            if let Some(block) = canonical_coverage_control_block(&control) {
5345                canonical_batch_block = Some(
5346                    canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
5347                );
5348            }
5349            self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
5350        }
5351
5352        // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched
5353        // addresses (after all handler effects, so the set is complete), then
5354        // fire the root gate only on cadence boundaries. The gate diffs
5355        // against persisted baselines, so skipped blocks lose no detection —
5356        // but the touched set must be the union since the last firing, or a
5357        // decoder-covered write in a skipped block would false-positive as a
5358        // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so
5359        // callers see them and `ingest_batch_with_resync` executes them) and
5360        // coverage reports go into the dispatched reports.
5361        if self.root_gate_runnable(cache) {
5362            self.touched_since_gate
5363                .extend(touched_addrs.iter().copied());
5364            if self.root_gate_due(canonical_batch_block) {
5365                let accumulated = std::mem::take(&mut self.touched_since_gate);
5366                self.run_root_gate(
5367                    cache,
5368                    canonical_batch_block,
5369                    &accumulated,
5370                    &mut batch_report.resyncs,
5371                    &mut reports_to_dispatch,
5372                );
5373                self.last_gate_block = canonical_batch_block;
5374            }
5375        } else {
5376            // A gate that cannot run (disabled, nothing root-gated, or no
5377            // proof fetcher) must not grow the accumulator unboundedly.
5378            // Dropping it is safe: without a runnable gate no baselines exist
5379            // (a fetcher cannot be uninstalled, and untracking drops the
5380            // baseline), so there is nothing a lost touched set could falsely
5381            // gap against later.
5382            self.touched_since_gate.clear();
5383        }
5384
5385        batch_report.reports = reports_to_dispatch;
5386        Ok(batch_report)
5387    }
5388
5389    /// Prove that every owner-only historical effect can be attached to an
5390    /// compatible retained canonical journal entry before any chain control or
5391    /// handler mutation is applied. Number/hash are exact. Parent/timestamp are
5392    /// optional enrichment, but two present values must agree; this matches the
5393    /// [`BlockRef`] compatibility rule used for cross-source deduplication.
5394    ///
5395    /// Owner catch-up deliberately does not advance canonical coverage. Its
5396    /// effects are appended to the already-existing journal entry so a later
5397    /// reorg can roll them back with the rest of that block. Accepting a block
5398    /// outside the journal would make the cache mutation irreversible. A reorg
5399    /// control in the same batch also invalidates entries above its ancestor,
5400    /// so those entries are rejected even though they still exist at this
5401    /// preflight point.
5402    fn validate_owner_catchup_against_journal(
5403        &self,
5404        control_state: &ChainControlState,
5405        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5406    ) -> Result<(), ReactiveError> {
5407        for (record, _, delivery_scope) in records {
5408            if *delivery_scope != DeliveryScope::OwnerCatchup {
5409                continue;
5410            }
5411            // Removed/reorged inputs are lifecycle signals only. Owner catch-up
5412            // cannot make them canonical and the record loop deliberately skips
5413            // handler execution, so there is no effect that needs attaching to
5414            // a rollback journal entry.
5415            if reorg_signal_block(record).is_some() {
5416                continue;
5417            }
5418            let context_block = canonical_record_block(record).ok_or_else(|| {
5419                ReactiveError::InvalidChainControl {
5420                    message: "owner catch-up input has no canonical block identity".into(),
5421                }
5422            })?;
5423            let block = resolve_record_block_payload_metadata(record, *context_block)?;
5424            let invalidated_by_control = control_state
5425                .journal_invalidated_from
5426                .is_some_and(|from| block.number >= from);
5427            let rollbackable = !invalidated_by_control
5428                && self.journal.iter().any(|entry| {
5429                    optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
5430                });
5431            if !rollbackable {
5432                return Err(ReactiveError::OwnerCatchupOutsideJournal {
5433                    number: block.number,
5434                    hash: block.hash,
5435                });
5436            }
5437        }
5438        Ok(())
5439    }
5440
5441    fn validate_owner_catchup_record_against_current_journal(
5442        &self,
5443        record: &ReactiveInputRecord<N>,
5444    ) -> Result<(), ReactiveError> {
5445        let context_block =
5446            canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
5447                message: "owner catch-up input has no canonical block identity".into(),
5448            })?;
5449        let block = resolve_record_block_payload_metadata(record, *context_block)?;
5450        if self
5451            .journal
5452            .iter()
5453            .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
5454        {
5455            return Ok(());
5456        }
5457        Err(ReactiveError::OwnerCatchupOutsideJournal {
5458            number: block.number,
5459            hash: block.hash,
5460        })
5461    }
5462
5463    /// Whether the root gate could produce any signal at all: some tracked
5464    /// account is root-gated (`Slots` never is) and a proof fetcher exists.
5465    /// When this is false the touched accumulator is dropped rather than
5466    /// grown (see the ingest call site for why that is safe).
5467    fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
5468        if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
5469            return false;
5470        }
5471        let has_gated_targets = self
5472            .tracking
5473            .values()
5474            .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
5475        has_gated_targets && cache.account_proof_fetcher().is_some()
5476    }
5477
5478    /// Whether the root gate is due at this batch's canonical block (§6.2):
5479    /// the first canonical block ever seen always fires (baseline adoption
5480    /// must not wait a full window), then at most once every `n` blocks.
5481    fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
5482        let Some(block) = canonical_block else {
5483            return false;
5484        };
5485        match self.root_gate_cadence {
5486            RootGateCadence::Disabled => false,
5487            RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
5488                None => true,
5489                Some(last) => block >= last.saturating_add(n.get()),
5490            },
5491        }
5492    }
5493
5494    /// The `storageHash` root gate (Phase-8 step 4), fired per
5495    /// [`RootGateCadence`] window (§6.2).
5496    ///
5497    /// Runs at the firing batch's canonical block, with `touched` carrying the
5498    /// union of decoder-touched addresses since the previous firing. For each tracked
5499    /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars)
5500    /// account, probe the root (and account fields) via the account-proof seam and
5501    /// apply the spec §4 table:
5502    ///
5503    /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap).
5504    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing.
5505    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched`
5506    ///   ⇒ a decoder covered it; re-adopt, no gap.
5507    /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched`
5508    ///   ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a
5509    ///   [`ResyncReason::RootMoved`] account resync, re-adopt.
5510    /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to
5511    ///   the baseline (native field changes never move the storage root); on a move
5512    ///   with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account
5513    ///   resync for the changed fields and re-adopt.
5514    ///
5515    /// No-op when the tracking registry is empty, when the batch has no canonical
5516    /// block, or when the cache has no account-proof fetcher installed.
5517    /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec
5518    /// Decision 3).
5519    fn run_root_gate(
5520        &mut self,
5521        cache: &EvmCache,
5522        canonical_block: Option<u64>,
5523        touched: &HashSet<Address>,
5524        resyncs: &mut Vec<ResyncRequest>,
5525        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5526    ) {
5527        if self.tracking.is_empty() {
5528            return;
5529        }
5530        let Some(block) = canonical_block else {
5531            return;
5532        };
5533        let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
5534            return;
5535        };
5536
5537        // Collect the root-gated targets (Slots opts out) in a stable order so a
5538        // single-block sequence of resyncs/reports is deterministic.
5539        let mut targets: Vec<(Address, bool)> = self
5540            .tracking
5541            .iter()
5542            .filter_map(|(address, policy)| match policy {
5543                TrackingPolicy::Slots { .. } => None,
5544                TrackingPolicy::WholeAccount => Some((*address, true)),
5545                TrackingPolicy::Scalars => Some((*address, false)),
5546            })
5547            .collect();
5548        if targets.is_empty() {
5549            return;
5550        }
5551        targets.sort_by_key(|(address, _)| *address);
5552
5553        let block_id = BlockId::number(block);
5554        // ONE seam invocation carries every root-gated target (root-only
5555        // probes: no storage keys needed). eth_getProof is single-address at
5556        // the RPC level, so batching here lets the fetcher fan the requests
5557        // out concurrently instead of paying N sequential round trips.
5558        let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
5559            targets
5560                .iter()
5561                .map(|&(address, _)| (address, vec![]))
5562                .collect(),
5563            block_id,
5564        )
5565        .into_iter()
5566        .collect();
5567        for (address, whole_account) in targets {
5568            let Some(Ok(proof)) = probes.remove(&address) else {
5569                // A failed/omitted probe carries no signal; leave the baseline
5570                // untouched and try again next block.
5571                continue;
5572            };
5573
5574            let baseline = self.tracked_roots.get(&address).cloned();
5575            let Some(baseline) = baseline else {
5576                // First observation: adopt the baseline. Not a coverage gap.
5577                self.adopt_root(address, block, &proof);
5578                continue;
5579            };
5580
5581            // A stale probe (a batch whose canonical block is not newer than the
5582            // last one we baselined this account against) carries no forward
5583            // signal: skip it rather than diff against — or clobber — a newer
5584            // baseline.
5585            if block <= baseline.last_block {
5586                continue;
5587            }
5588
5589            if whole_account {
5590                if proof.storage_hash == baseline.last_root {
5591                    // Tight steady-state path: unchanged root ⇒ nothing.
5592                    continue;
5593                }
5594                // Root moved.
5595                if !touched.contains(&address) {
5596                    // Moved with no covering decoder — the coverage gap.
5597                    reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
5598                        address,
5599                        block,
5600                        _network: PhantomData,
5601                    })));
5602                    self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
5603                    resyncs.push(root_moved_account_resync(
5604                        address,
5605                        block,
5606                        AccountFieldMask {
5607                            balance: true,
5608                            nonce: true,
5609                            code: true,
5610                        },
5611                    ));
5612                }
5613                // Adopt the new root whether or not a decoder covered it.
5614                self.adopt_root(address, block, &proof);
5615            } else {
5616                // Scalars: compare the account fields directly (native changes do
5617                // not move the storage root).
5618                let balance_moved = proof.balance != baseline.balance;
5619                let nonce_moved = proof.nonce != baseline.nonce;
5620                let code_moved = proof.code_hash != baseline.code_hash;
5621                if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
5622                    resyncs.push(root_moved_account_resync(
5623                        address,
5624                        block,
5625                        AccountFieldMask {
5626                            balance: balance_moved,
5627                            nonce: nonce_moved,
5628                            code: code_moved,
5629                        },
5630                    ));
5631                }
5632                self.adopt_root(address, block, &proof);
5633            }
5634        }
5635    }
5636
5637    /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`.
5638    fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
5639        self.tracked_roots.insert(
5640            address,
5641            TrackedRoot {
5642                last_root: proof.storage_hash,
5643                last_block: block,
5644                balance: proof.balance,
5645                nonce: proof.nonce,
5646                code_hash: proof.code_hash,
5647            },
5648        );
5649    }
5650
5651    fn execute_handlers(
5652        &self,
5653        cache: &EvmCache,
5654        record: &ReactiveInputRecord<N>,
5655        input_ref: InputRef,
5656        audience: &DeliveryAudience,
5657    ) -> Result<Vec<HandlerExecution>, ReactiveError> {
5658        let mut executions = Vec::new();
5659        let candidates: Vec<_> = match &record.input {
5660            ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
5661            ReactiveInput::BlockHeader(_)
5662            | ReactiveInput::FullBlock(_)
5663            | ReactiveInput::PendingTxHash(_)
5664            | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
5665        };
5666        for registered in candidates {
5667            match audience {
5668                DeliveryAudience::Owners(owners) if !owners.contains(&registered.id) => continue,
5669                DeliveryAudience::AllExcept(excluded) if excluded.contains(&registered.id) => {
5670                    continue;
5671                }
5672                DeliveryAudience::All
5673                | DeliveryAudience::Owners(_)
5674                | DeliveryAudience::AllExcept(_) => {}
5675            }
5676            if !registered.matches(&record.input) {
5677                continue;
5678            }
5679
5680            let outcome = registered
5681                .handler
5682                .handle(&record.context, &record.input, cache)
5683                .map_err(|source| ReactiveError::HandlerFailed {
5684                    handler_id: registered.id.clone(),
5685                    source,
5686                })?;
5687
5688            if let Err(error) =
5689                validate_effects(input_ref, &record.context, &registered.id, &outcome.effects)
5690            {
5691                if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
5692                    self.metrics
5693                        .pending_contamination
5694                        .fetch_add(1, Ordering::Relaxed);
5695                }
5696                return Err(error);
5697            }
5698            executions.push(HandlerExecution::from_outcome(
5699                registered.id.clone(),
5700                input_ref,
5701                outcome,
5702                matches!(
5703                    record.context.chain_status,
5704                    ChainStatus::Preconfirmed { .. }
5705                ),
5706            ));
5707        }
5708        Ok(executions)
5709    }
5710
5711    fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
5712        for report in reports {
5713            for hook in &self.hooks {
5714                hook.on_report(report.clone());
5715            }
5716        }
5717    }
5718
5719    fn apply_chain_control(
5720        &mut self,
5721        cache: &mut EvmCache,
5722        control: ChainControl,
5723        batch_report: &mut ReactiveBatchReport<N>,
5724        reports: &mut Vec<Arc<ReactiveReport<N>>>,
5725    ) {
5726        match &control {
5727            ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
5728            ChainControl::Finalized(block) => {
5729                set_or_enrich_block_ref(&mut self.finalized_head, block);
5730            }
5731            ChainControl::CanonicalProgress(block)
5732            | ChainControl::Barrier {
5733                block: Some(block), ..
5734            } => {
5735                let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
5736                    optional_block_refs_are_compatible(Some(current), Some(block))
5737                });
5738                cache.advance_compact_block(
5739                    block.number,
5740                    block.hash,
5741                    block.timestamp,
5742                    preserve_env,
5743                );
5744                advance_or_enrich_coverage(&mut self.coverage_head, block);
5745                let enriched = self.journal_entry_mut(block).block;
5746                advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
5747                self.trim_journal();
5748            }
5749            ChainControl::Barrier { block: None, .. } => {}
5750            ChainControl::Reorg {
5751                common_ancestor,
5752                old_tip,
5753                ..
5754            } => {
5755                cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
5756                self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
5757                let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
5758                    entry.block.number == common_ancestor.number
5759                        && entry.block.hash == common_ancestor.hash
5760                }) {
5761                    self.drain_journal_after(ancestor_index)
5762                } else {
5763                    // Sparse journals are expected for blocks with no matching
5764                    // events. If the oldest retained entry is at or below the
5765                    // ancestor, every effect above it is still present and the
5766                    // rollback is complete even without an exact anchor.
5767                    if self
5768                        .journal
5769                        .front()
5770                        .is_none_or(|entry| entry.block.number > common_ancestor.number)
5771                    {
5772                        reports.extend(
5773                            self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
5774                        );
5775                    }
5776                    self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
5777                };
5778
5779                let reorg_report = self
5780                    .recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
5781                    .unwrap_or_else(|| ReorgReport {
5782                        dropped: Some(*old_tip),
5783                        dropped_blocks: Vec::new(),
5784                        dropped_inputs: Vec::new(),
5785                        rollback_updates: Vec::new(),
5786                        rollback_diff: StateDiff::default(),
5787                        purge_updates: Vec::new(),
5788                        purge_diff: StateDiff::default(),
5789                        canceled_resyncs: self
5790                            .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
5791                        reason: ReorgReason::Explicit,
5792                        _network: PhantomData,
5793                    });
5794                remove_canceled_resyncs_from_batch(
5795                    &mut batch_report.resyncs,
5796                    &reorg_report.canceled_resyncs,
5797                );
5798                self.metrics
5799                    .reorgs_recovered
5800                    .fetch_add(1, Ordering::Relaxed);
5801                reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
5802
5803                if self.safe_head.as_ref().is_some_and(|head| {
5804                    head.number > common_ancestor.number
5805                        || (head.number == common_ancestor.number
5806                            && head.hash != common_ancestor.hash)
5807                }) {
5808                    self.safe_head = None;
5809                }
5810                if self.finalized_head.as_ref().is_some_and(|head| {
5811                    head.number > common_ancestor.number
5812                        || (head.number == common_ancestor.number
5813                            && head.hash != common_ancestor.hash)
5814                }) {
5815                    self.finalized_head = None;
5816                }
5817                let mut enriched_ancestor = *common_ancestor;
5818                if let Some(entry) = self.journal.iter().find(|entry| {
5819                    entry.block.number == common_ancestor.number
5820                        && entry.block.hash == common_ancestor.hash
5821                }) {
5822                    enrich_block_ref(&mut enriched_ancestor, &entry.block);
5823                }
5824                if let Some(current) = self.coverage_head.as_ref()
5825                    && current.number == common_ancestor.number
5826                    && current.hash == common_ancestor.hash
5827                {
5828                    enrich_block_ref(&mut enriched_ancestor, current);
5829                }
5830                self.coverage_head = Some(enriched_ancestor);
5831                cache.advance_compact_block(
5832                    enriched_ancestor.number,
5833                    enriched_ancestor.hash,
5834                    enriched_ancestor.timestamp,
5835                    false,
5836                );
5837                let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
5838                self.coverage_head = Some(enriched_ancestor);
5839                self.trim_journal();
5840            }
5841        }
5842        reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
5843            control,
5844        })));
5845    }
5846
5847    fn validate_ingest_sequence(
5848        &self,
5849        pre_record_controls: &[ChainControl],
5850        post_record_controls: &[ChainControl],
5851        records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
5852    ) -> Result<ChainControlState, ReactiveError> {
5853        let mut controls =
5854            Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
5855        controls.extend_from_slice(pre_record_controls);
5856        controls.extend_from_slice(post_record_controls);
5857        let state = CanonicalSequenceState::new(
5858            self.journal.iter().map(|entry| entry.block).collect(),
5859            self.coverage_head,
5860            self.safe_head,
5861            self.finalized_head,
5862        );
5863        let record_metadata = records
5864            .iter()
5865            .map(|(record, _, scope)| (record, *scope))
5866            .collect::<Vec<_>>();
5867        let validation = validate_canonical_sequence_parts(
5868            &state,
5869            &controls,
5870            &record_metadata,
5871            CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
5872        )
5873        .map_err(CanonicalSequenceError::into_reactive_error)?;
5874        let mut resolved_canonical_blocks = HashMap::new();
5875        for mutation in validation.mutations() {
5876            if let CanonicalSequenceMutation::Canonical(block) = mutation {
5877                resolved_canonical_blocks
5878                    .entry((block.number, block.hash))
5879                    .and_modify(|known| enrich_block_ref(known, block))
5880                    .or_insert(*block);
5881            }
5882        }
5883        Ok(ChainControlState {
5884            journal_invalidated_from: pre_record_controls
5885                .iter()
5886                .filter_map(|control| match control {
5887                    ChainControl::Reorg {
5888                        common_ancestor, ..
5889                    } => Some(common_ancestor.number.saturating_add(1)),
5890                    _ => None,
5891                })
5892                .min(),
5893            resolved_canonical_blocks,
5894        })
5895    }
5896
5897    fn recover_for_canonical_input(
5898        &mut self,
5899        cache: &mut EvmCache,
5900        block: &BlockRef,
5901        gap_is_certified: bool,
5902        parentless_replacement_is_proven: bool,
5903        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
5904    ) -> Option<ReorgReport<N>> {
5905        let latest = self
5906            .coverage_head
5907            .or_else(|| self.journal.back().map(|entry| entry.block))?;
5908
5909        if latest.number == block.number && latest.hash == block.hash {
5910            return None;
5911        }
5912
5913        if self
5914            .journal
5915            .iter()
5916            .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
5917        {
5918            return None;
5919        }
5920
5921        if latest.number.checked_add(1) == Some(block.number)
5922            && (block.parent_hash == Some(latest.hash)
5923                || (parentless_replacement_is_proven && block.parent_hash.is_none()))
5924        {
5925            return None;
5926        }
5927
5928        if latest
5929            .number
5930            .checked_add(1)
5931            .is_some_and(|next| block.number > next)
5932        {
5933            // A forward gap: blocks between the journaled head and the arriving
5934            // block were never observed (e.g. a disconnect). A historical
5935            // canonical-progress delivery can instead be covered by a
5936            // compatible post-record progress/barrier certificate proving the
5937            // sparse interval contained no matching events. Live canonical
5938            // gaps remain observable and escalate health.
5939            if !gap_is_certified {
5940                self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
5941                health_reports.extend(self.escalate_trust(block.number));
5942                health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
5943                    MissedRangeReport {
5944                        from: latest.number + 1,
5945                        to: block.number - 1,
5946                        block: block.number,
5947                        _network: PhantomData,
5948                    },
5949                )));
5950            }
5951            return None;
5952        }
5953
5954        let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
5955            if let Some(parent_index) = self.journal.iter().rposition(|entry| {
5956                entry.block.number.checked_add(1) == Some(block.number)
5957                    && entry.block.hash == parent_hash
5958            }) {
5959                let parent = self.journal[parent_index].block;
5960                cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
5961                (self.drain_journal_after(parent_index), Some(parent))
5962            } else {
5963                // An unknown immediate parent proves exactly N-1 and nothing
5964                // earlier. Preserve a prefix only when the accepted path is an
5965                // immediate child of the runtime's exact finalized anchor;
5966                // otherwise every cached BLOCKHASH may belong to the displaced
5967                // branch and must be cleared fail-closed.
5968                let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
5969                    finalized.number.checked_add(1) == Some(block.number)
5970                        && parent_hash == finalized.hash
5971                });
5972                let invalidated_from = proven_finalized_anchor
5973                    .map_or(0, |finalized| finalized.number.saturating_add(1));
5974                cache.invalidate_cached_block_hashes_from(invalidated_from);
5975                if block.number > 0 {
5976                    // Even when the parent falls outside the retained journal,
5977                    // the arriving child authenticates its exact hash. Restore
5978                    // that one known value after clearing the displaced branch.
5979                    cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
5980                }
5981                health_reports.extend(self.warn_under_recovery(block.number));
5982                let dropped = if let Some(finalized) = proven_finalized_anchor {
5983                    self.drain_journal_from_number(finalized.number.saturating_add(1))
5984                } else {
5985                    self.drain_journal_from_number(0)
5986                };
5987                (dropped, proven_finalized_anchor)
5988            }
5989        } else {
5990            // No parent identity authenticates any prefix of the arriving path.
5991            cache.invalidate_cached_block_hashes_from(0);
5992            health_reports.extend(self.warn_under_recovery(block.number));
5993            (self.drain_journal_from_number(0), None)
5994        };
5995
5996        self.rebase_validation_state_from(
5997            authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
5998        );
5999        let report = self
6000            .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
6001            .or_else(|| {
6002                Some(ReorgReport {
6003                    dropped: Some(latest),
6004                    dropped_blocks: Vec::new(),
6005                    dropped_inputs: Vec::new(),
6006                    rollback_updates: Vec::new(),
6007                    rollback_diff: StateDiff::default(),
6008                    purge_updates: Vec::new(),
6009                    purge_diff: StateDiff::default(),
6010                    canceled_resyncs: self
6011                        .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
6012                    reason: ReorgReason::ParentMismatch,
6013                    _network: PhantomData,
6014                })
6015            });
6016        self.coverage_head = authenticated_anchor;
6017        for head in [&mut self.safe_head, &mut self.finalized_head] {
6018            if head.is_some_and(|head| {
6019                authenticated_anchor.is_none_or(|anchor| {
6020                    head.number > anchor.number
6021                        || (head.number == anchor.number && head.hash != anchor.hash)
6022                })
6023            }) {
6024                *head = None;
6025            }
6026        }
6027        if let Some(anchor) = authenticated_anchor {
6028            cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
6029        }
6030        report
6031    }
6032
6033    fn recover_for_reorged_input(
6034        &mut self,
6035        cache: &mut EvmCache,
6036        record: &ReactiveInputRecord<N>,
6037        batch_dropped: &mut BatchDroppedCanonical,
6038        health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
6039    ) -> Option<ReorgReport<N>> {
6040        let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
6041        if batch_dropped.contains(&incoming_dropped_block) {
6042            // A previous signal in this atomic batch already drained this
6043            // block/span. Preserve the lifecycle input report, but do not
6044            // repeat rollback or classify the provider's per-log removals as a
6045            // deep reorg. Exact hash-pinned repairs still need cancellation.
6046            let canceled_resyncs = self
6047                .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
6048            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6049                dropped: Some(incoming_dropped_block),
6050                dropped_blocks: vec![incoming_dropped_block],
6051                dropped_inputs: Vec::new(),
6052                rollback_updates: Vec::new(),
6053                rollback_diff: StateDiff::default(),
6054                purge_updates: Vec::new(),
6055                purge_diff: StateDiff::default(),
6056                canceled_resyncs,
6057                reason,
6058                _network: PhantomData,
6059            });
6060        }
6061        let exact_index = self.journal.iter().position(|entry| {
6062            entry.block.number == incoming_dropped_block.number
6063                && entry.block.hash == incoming_dropped_block.hash
6064        });
6065        let mut dropped_block = exact_index
6066            .map(|index| self.journal[index].block)
6067            .or_else(|| {
6068                self.coverage_head.filter(|known| {
6069                    known.number == incoming_dropped_block.number
6070                        && known.hash == incoming_dropped_block.hash
6071                })
6072            })
6073            .unwrap_or(incoming_dropped_block);
6074        enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6075        let replacement_is_known = exact_index.is_none()
6076            && (self.journal.iter().any(|entry| {
6077                entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
6078            }) || self.coverage_head.is_some_and(|head| {
6079                head.number == dropped_block.number && head.hash != dropped_block.hash
6080            }));
6081
6082        if replacement_is_known {
6083            // A delayed/duplicate removed log for the displaced hash is
6084            // idempotent. Draining by number here would destroy the already
6085            // installed replacement branch at the same height.
6086            let canceled_resyncs =
6087                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6088            return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
6089                dropped: Some(dropped_block),
6090                dropped_blocks: vec![dropped_block],
6091                dropped_inputs: Vec::new(),
6092                rollback_updates: Vec::new(),
6093                rollback_diff: StateDiff::default(),
6094                purge_updates: Vec::new(),
6095                purge_diff: StateDiff::default(),
6096                canceled_resyncs,
6097                reason,
6098                _network: PhantomData,
6099            });
6100        }
6101
6102        let authenticated_anchor = exact_index.and_then(|index| {
6103            let ancestor_number = dropped_block.number.checked_sub(1)?;
6104            let retained = self
6105                .journal
6106                .iter()
6107                .take(index)
6108                .rev()
6109                .find(|entry| entry.block.number == ancestor_number)
6110                .map(|entry| entry.block);
6111            let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
6112                number: ancestor_number,
6113                hash,
6114                parent_hash: None,
6115                timestamp: None,
6116            });
6117            let finalized_fallback = self
6118                .finalized_head
6119                .filter(|head| head.number == ancestor_number);
6120            let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
6121            for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
6122                .into_iter()
6123                .flatten()
6124            {
6125                if head.number == anchor.number && head.hash == anchor.hash {
6126                    enrich_block_ref(&mut anchor, head);
6127                }
6128            }
6129            Some(anchor)
6130        });
6131
6132        cache.invalidate_cached_block_hashes_from(dropped_block.number);
6133        let dropped = if let Some(index) = exact_index {
6134            self.drain_journal_from(index)
6135        } else {
6136            health_reports.extend(self.warn_under_recovery(dropped_block.number));
6137            self.drain_journal_from_number(dropped_block.number)
6138        };
6139        let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
6140        batch_dropped.record_drained(&drained_blocks);
6141        batch_dropped.record_identity(&dropped_block);
6142        self.rebase_validation_state_from(dropped_block.number);
6143
6144        let recovered_journal = !dropped.is_empty();
6145        let report = if !recovered_journal {
6146            let canceled_resyncs =
6147                self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
6148            Some(ReorgReport {
6149                dropped: Some(dropped_block),
6150                dropped_blocks: Vec::new(),
6151                dropped_inputs: Vec::new(),
6152                rollback_updates: Vec::new(),
6153                rollback_diff: StateDiff::default(),
6154                purge_updates: Vec::new(),
6155                purge_diff: StateDiff::default(),
6156                canceled_resyncs,
6157                reason,
6158                _network: PhantomData,
6159            })
6160        } else {
6161            self.recover_dropped_journals(cache, dropped, reason)
6162        };
6163
6164        if recovered_journal {
6165            if let Some(anchor) = authenticated_anchor {
6166                self.coverage_head = Some(anchor);
6167            }
6168            let coverage = self.coverage_head;
6169            for head in [&mut self.safe_head, &mut self.finalized_head] {
6170                if head.is_some_and(|head| {
6171                    coverage.is_none_or(|coverage| {
6172                        head.number > coverage.number
6173                            || (head.number == coverage.number && head.hash != coverage.hash)
6174                    })
6175                }) {
6176                    *head = None;
6177                }
6178            }
6179        }
6180
6181        if recovered_journal
6182            && report.is_some()
6183            && let Some(head) = self.coverage_head
6184        {
6185            cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
6186        }
6187        report
6188    }
6189
6190    /// Warn that a reorg references a block no longer resident in the journal, so
6191    /// recovery is limited to the blocks still journaled — effects from aged-out
6192    /// blocks are neither rolled back nor purged (the freshness/validation loop is
6193    /// the backstop). Makes the under-recovery observable instead of silent.
6194    ///
6195    /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates
6196    /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust)
6197    /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to
6198    /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`]
6199    /// transition is returned so the caller can thread it into the ingest cycle's
6200    /// dispatched reports.
6201    fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
6202        let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
6203        tracing::warn!(
6204            reorg_block = reorg_number,
6205            oldest_journaled = ?oldest_journaled,
6206            journal_depth = self.config.journal_depth,
6207            "reactive reorg recovery is incomplete: the reorged block is no longer \
6208             in the journal, so effects from blocks aged out of the journal are \
6209             neither rolled back nor purged (the freshness/validation loop is the \
6210             backstop). Increase ReactiveConfig::journal_depth to recover deeper \
6211             reorgs precisely."
6212        );
6213
6214        self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
6215
6216        self.escalate_trust(reorg_number)
6217    }
6218
6219    fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
6220        advance_or_enrich_coverage(&mut self.coverage_head, block);
6221        let entry = self.journal_entry_mut(block);
6222        let enriched = entry.block;
6223        if !entry.inputs.contains(&input_ref) {
6224            entry.inputs.push(input_ref);
6225        }
6226        advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
6227        self.trim_journal();
6228    }
6229
6230    fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6231        let entry = self.journal_entry_mut(block);
6232        if !entry.handler_ids.contains(&applied.handler_id) {
6233            entry.handler_ids.push(applied.handler_id.clone());
6234        }
6235        entry.rollback_diffs.push(applied.diff.clone());
6236        entry.applied.push(applied);
6237        self.trim_journal();
6238    }
6239
6240    fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
6241        let Some(entry) = self
6242            .journal
6243            .iter_mut()
6244            .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
6245        else {
6246            return;
6247        };
6248        if !entry.handler_ids.contains(&applied.handler_id) {
6249            entry.handler_ids.push(applied.handler_id.clone());
6250        }
6251        entry.rollback_diffs.push(applied.diff.clone());
6252        entry.applied.push(applied);
6253    }
6254
6255    fn record_journal_resync(&mut self, report: &ResyncReport) {
6256        if report.diff.is_empty() {
6257            return;
6258        }
6259        let Some(block) = single_hash_pinned_resync_block(report) else {
6260            return;
6261        };
6262        let entry = self.journal_entry_mut(&block);
6263        entry.rollback_diffs.push(report.diff.clone());
6264        entry.resynced.push(report.clone());
6265        self.trim_journal();
6266    }
6267
6268    fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
6269        if let Some(index) = self
6270            .journal
6271            .iter()
6272            .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
6273        {
6274            enrich_block_ref(&mut self.journal[index].block, block);
6275            return &mut self.journal[index];
6276        }
6277
6278        self.journal.push_back(BlockJournal {
6279            block: *block,
6280            inputs: Vec::new(),
6281            applied: Vec::new(),
6282            handler_ids: Vec::new(),
6283            resynced: Vec::new(),
6284            rollback_diffs: Vec::new(),
6285        });
6286        let index = self.journal.len() - 1;
6287        &mut self.journal[index]
6288    }
6289
6290    fn trim_journal(&mut self) {
6291        if self.config.journal_depth == 0 {
6292            self.journal.clear();
6293            return;
6294        }
6295        while self.journal.len() > self.config.journal_depth {
6296            self.journal.pop_front();
6297        }
6298    }
6299
6300    fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6301        self.journal.drain((index + 1)..).collect()
6302    }
6303
6304    fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
6305        self.journal.drain(index..).collect()
6306    }
6307
6308    fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
6309        let Some(index) = self
6310            .journal
6311            .iter()
6312            .position(|entry| entry.block.number >= number)
6313        else {
6314            return Vec::new();
6315        };
6316        self.drain_journal_from(index)
6317    }
6318
6319    fn recover_dropped_journals(
6320        &mut self,
6321        cache: &mut EvmCache,
6322        dropped: Vec<BlockJournal<N>>,
6323        reason: ReorgReason,
6324    ) -> Option<ReorgReport<N>> {
6325        if dropped.is_empty() {
6326            return None;
6327        }
6328
6329        let first_dropped_block = dropped
6330            .iter()
6331            .map(|entry| entry.block.number)
6332            .min()
6333            .expect("non-empty dropped journal set");
6334        self.rebase_validation_state_from(first_dropped_block);
6335        if self
6336            .safe_head
6337            .is_some_and(|head| head.number >= first_dropped_block)
6338        {
6339            self.safe_head = None;
6340        }
6341
6342        let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect();
6343        let dropped_inputs: Vec<_> = dropped
6344            .iter()
6345            .flat_map(|entry| entry.inputs.iter().copied())
6346            .collect();
6347        let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
6348        let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
6349        let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
6350        let purge_updates: Vec<_> = purge_scopes
6351            .iter()
6352            .map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
6353            .collect();
6354
6355        let rollback_diff = if rollback_updates.is_empty() {
6356            StateDiff::default()
6357        } else {
6358            cache.apply_updates(&rollback_updates)
6359        };
6360        let purge_diff = if purge_updates.is_empty() {
6361            StateDiff::default()
6362        } else {
6363            cache.apply_updates(&purge_updates)
6364        };
6365        self.coverage_head = self.journal.back().map(|entry| entry.block);
6366
6367        Some(ReorgReport {
6368            dropped: dropped_blocks.first().cloned(),
6369            dropped_blocks,
6370            dropped_inputs,
6371            rollback_updates,
6372            rollback_diff,
6373            purge_updates,
6374            purge_diff,
6375            canceled_resyncs,
6376            reason,
6377            _network: PhantomData,
6378        })
6379    }
6380
6381    fn rebase_validation_state_from(&mut self, first_dropped_block: u64) {
6382        if let Some(freshness) = self.freshness.as_mut() {
6383            freshness.invalidate_valid_through_from(first_dropped_block);
6384        }
6385        self.tracked_roots
6386            .retain(|_, baseline| baseline.last_block < first_dropped_block);
6387        if self
6388            .last_gate_block
6389            .is_some_and(|block| block >= first_dropped_block)
6390        {
6391            self.last_gate_block = self
6392                .tracked_roots
6393                .values()
6394                .map(|baseline| baseline.last_block)
6395                .max();
6396        }
6397        // Touch provenance is window-relative. Once any block in that window
6398        // is dropped, retaining the union could incorrectly mark a replacement
6399        // branch root move as decoder-covered.
6400        self.touched_since_gate.clear();
6401    }
6402
6403    fn cancel_resyncs_for_dropped_blocks(
6404        &mut self,
6405        dropped_blocks: &[BlockRef],
6406    ) -> Vec<ResyncRequest> {
6407        let mut canceled = Vec::new();
6408        self.pending_resyncs.retain(|request| {
6409            let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
6410            if should_cancel {
6411                canceled.push(request.clone());
6412            }
6413            !should_cancel
6414        });
6415        canceled
6416    }
6417
6418    fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
6419        let ids: HashSet<_> = ids.into_iter().cloned().collect();
6420        self.pending_resyncs
6421            .retain(|request| !ids.contains(&request.id));
6422    }
6423}
6424
6425fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
6426    cache.set_block(BlockId::pending());
6427    cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
6428    cache.set_coinbase(flashblock.beneficiary);
6429    cache.set_prevrandao(flashblock.prevrandao);
6430    cache.set_block_gas_limit(flashblock.gas_limit);
6431    cache.set_timestamp(flashblock.timestamp);
6432}
6433
6434/// Validate one provider-neutral delivery envelope without mutating runtime or
6435/// cache state.
6436///
6437/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and
6438/// composite/remote subscribers. It validates explicit reorg controls before
6439/// records, canonical record identity and implicit-reorg finality, then
6440/// progress/barrier/safe/finalized controls. All identity assertions in the
6441/// envelope must agree at each height. Retained history may be sparse; an
6442/// explicit common ancestor need not itself be retained when the oldest
6443/// retained entry is at or below it. Ancestors and removed blocks outside that
6444/// rollback horizon are rejected, so a durable caller cannot persist a partial
6445/// rollback. The runtime uses this same implementation with an internal
6446/// observable-deep-reorg policy for its deliberately non-durable ingest path.
6447///
6448/// The returned state and mutations are cache-free. Callers that durably stage
6449/// delivery should publish/persist them only at their own acknowledgement
6450/// boundary.
6451///
6452/// This validator is deliberately chain-agnostic and does not compare
6453/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries
6454/// no chain id. Cross-service/composite callers must bind one authoritative
6455/// chain identity outside this state before sharing or advancing it; runtime
6456/// ingestion separately checks the batch id against [`EvmCache`].
6457///
6458/// # Errors
6459///
6460/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload
6461/// metadata is malformed or conflicting, and
6462/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an
6463/// invalid canonical transition, incomplete rollback proof, contradictory
6464/// identity, or invalid coverage/finality relationship.
6465pub fn validate_canonical_sequence<N: Network>(
6466    state: &CanonicalSequenceState,
6467    batch: &ReactiveInputBatch<N>,
6468) -> Result<CanonicalSequenceValidation, ReactiveError> {
6469    validate_canonical_sequence_diagnostic(state, batch)
6470        .map_err(CanonicalSequenceError::into_reactive_error)
6471}
6472
6473/// Validate one provider-neutral delivery envelope and retain structured
6474/// rollback diagnostics.
6475///
6476/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use
6477/// it at durable/composite source boundaries that need to distinguish malformed
6478/// input from an otherwise valid transition whose rollback ancestor has aged
6479/// out of the retained history. Callers should branch on
6480/// [`CanonicalSequenceError`] rather than parsing error text.
6481///
6482/// # Errors
6483///
6484/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory
6485/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more
6486/// retained canonical history is required to prove the transition.
6487pub fn validate_canonical_sequence_diagnostic<N: Network>(
6488    state: &CanonicalSequenceState,
6489    batch: &ReactiveInputBatch<N>,
6490) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6491    validate_canonical_sequence_internal(
6492        state,
6493        batch,
6494        CanonicalSequenceValidationPolicy::RequireCompleteRollback,
6495    )
6496}
6497
6498/// Validate a composite-source envelope and normalize harmless coverage
6499/// overlap.
6500///
6501/// This has the same fail-closed rollback/finality/identity contract as
6502/// [`validate_canonical_sequence`]. In addition, an equal or older
6503/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is
6504/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`].
6505/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the
6506/// same opaque id and `block: None`, preserving the synchronization event
6507/// without forwarding regressive coverage. An equal-height control that fills
6508/// absent parent/timestamp metadata is retained and applied. Older compatible
6509/// metadata enrichment is deliberately dropped together with its non-forwarded
6510/// control so the returned state remains identical to what the runtime will
6511/// observe. Unknown or conflicting stale identities remain errors.
6512///
6513/// # Errors
6514///
6515/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting
6516/// record identity/payload metadata, and
6517/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be
6518/// proven redundant or when rollback, adjacency, identity, coverage, or
6519/// finality validation fails.
6520pub fn normalize_and_validate_canonical_sequence<N: Network>(
6521    state: &CanonicalSequenceState,
6522    batch: &ReactiveInputBatch<N>,
6523) -> Result<CanonicalSequenceValidation, ReactiveError> {
6524    normalize_and_validate_canonical_sequence_diagnostic(state, batch)
6525        .map_err(CanonicalSequenceError::into_reactive_error)
6526}
6527
6528/// Validate and normalize one composite-source envelope while retaining
6529/// structured rollback diagnostics.
6530///
6531/// This is the diagnostic counterpart to
6532/// [`normalize_and_validate_canonical_sequence`]. It has identical transition
6533/// and normalization semantics, but reports history exhaustion as
6534/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a
6535/// prose [`ReactiveError::InvalidChainControl`].
6536///
6537/// # Errors
6538///
6539/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or
6540/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`]
6541/// when the retained history cannot prove a complete rollback.
6542pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
6543    state: &CanonicalSequenceState,
6544    batch: &ReactiveInputBatch<N>,
6545) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6546    validate_canonical_sequence_internal(
6547        state,
6548        batch,
6549        CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
6550    )
6551}
6552
6553fn validate_canonical_sequence_internal<N: Network>(
6554    state: &CanonicalSequenceState,
6555    batch: &ReactiveInputBatch<N>,
6556    policy: CanonicalSequenceValidationPolicy,
6557) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6558    let records = batch
6559        .records()
6560        .iter()
6561        .enumerate()
6562        .map(|(index, record)| {
6563            (
6564                record.clone(),
6565                DeliveryAudience::All,
6566                batch
6567                    .record_delivery_scope(index)
6568                    .expect("enumerated record always has a delivery scope"),
6569            )
6570        })
6571        .collect::<Vec<_>>();
6572    let records = sort_scoped_records(dedupe_scoped_records(records)?);
6573    let records = records
6574        .iter()
6575        .map(|(record, _, scope)| (record, *scope))
6576        .collect::<Vec<_>>();
6577    validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
6578}
6579
6580#[derive(Clone, Copy)]
6581enum CanonicalSequenceValidationPolicy {
6582    RequireCompleteRollback,
6583    RequireCompleteRollbackNormalizeCoverage,
6584    ObserveIncompleteRollback,
6585}
6586
6587/// Stable category for a canonical transition that needs older retained
6588/// history before it can be durably accepted.
6589#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6590#[non_exhaustive]
6591pub enum CanonicalRollbackKind {
6592    /// An explicit reorg control names an ancestor outside retained history.
6593    Explicit,
6594    /// A removed/reorged record names a block outside retained history.
6595    Removed,
6596    /// An implicit canonical replacement has no retained parent proof.
6597    ImplicitParent,
6598    /// A removed block is not followed by a provable replacement/anchor.
6599    MissingReplacement,
6600}
6601
6602/// Structured failure returned by canonical-sequence diagnostic validation.
6603///
6604/// This type is intentionally independent of diagnostic prose so remote and
6605/// composite subscribers can select recovery behavior without string matching.
6606#[derive(Debug, thiserror::Error)]
6607#[non_exhaustive]
6608pub enum CanonicalSequenceError {
6609    /// The snapshot or envelope is intrinsically malformed or contradictory.
6610    #[error(transparent)]
6611    Invalid(#[from] ReactiveError),
6612    /// The transition may be valid, but its rollback proof lies outside the
6613    /// supplied retained canonical history.
6614    #[error(
6615        "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6616    )]
6617    IncompleteRollback {
6618        /// Last ancestor height required to prove the rollback.
6619        common_ancestor: u64,
6620        /// Oldest retained canonical height supplied by the caller.
6621        oldest_retained: Option<u64>,
6622        /// Stable reason the history window is insufficient.
6623        kind: CanonicalRollbackKind,
6624    },
6625}
6626
6627#[derive(Clone, Copy, Debug)]
6628struct RequiredReorgAnchor {
6629    number: u64,
6630    block: Option<BlockRef>,
6631    permits_missing_child_parent: bool,
6632    must_be_consumed: bool,
6633}
6634
6635#[derive(Debug)]
6636struct SequenceRewind {
6637    common_ancestor: Option<BlockRef>,
6638    dropped: Vec<BlockRef>,
6639}
6640
6641impl RequiredReorgAnchor {
6642    const fn hash(self) -> Option<B256> {
6643        match self.block {
6644            Some(block) => Some(block.hash),
6645            None => None,
6646        }
6647    }
6648}
6649
6650impl CanonicalSequenceError {
6651    /// Whether retrying with an older retained history window may prove this
6652    /// same transition.
6653    pub const fn requires_history(&self) -> bool {
6654        matches!(self, Self::IncompleteRollback { .. })
6655    }
6656
6657    /// Fold this structured diagnostic into the legacy ergonomic runtime error.
6658    pub fn into_reactive_error(self) -> ReactiveError {
6659        match self {
6660            Self::Invalid(error) => error,
6661            Self::IncompleteRollback {
6662                common_ancestor,
6663                oldest_retained,
6664                kind,
6665            } => ReactiveError::InvalidChainControl {
6666                message: format!(
6667                    "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
6668                ),
6669            },
6670        }
6671    }
6672}
6673
6674impl CanonicalSequenceValidationPolicy {
6675    const fn requires_complete_rollback(self) -> bool {
6676        matches!(
6677            self,
6678            Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
6679        )
6680    }
6681
6682    const fn normalizes_coverage(self) -> bool {
6683        matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
6684    }
6685}
6686
6687fn validate_canonical_sequence_parts<N: Network>(
6688    initial: &CanonicalSequenceState,
6689    controls: &[ChainControl],
6690    records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
6691    policy: CanonicalSequenceValidationPolicy,
6692) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
6693    validate_canonical_sequence_snapshot(initial)?;
6694    let control_split = validate_control_phase_order(controls)?;
6695    let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
6696    let mut state = initial.clone();
6697    let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
6698    let mut mutations = Vec::new();
6699    let mut normalized_chain_controls = Vec::with_capacity(controls.len());
6700    let mut batch_dropped = BatchDroppedCanonical::default();
6701    let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
6702    let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
6703    let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
6704    let rollback_oldest = initial
6705        .retained_canonical_history
6706        .first()
6707        .map(|block| block.number);
6708
6709    for control in pre_record_controls {
6710        normalized_chain_controls.push(control.clone());
6711        validate_sequence_control(&state, control)?;
6712        assert_chain_control_identities(&mut asserted_blocks, control)?;
6713        let ChainControl::Reorg {
6714            common_ancestor,
6715            old_tip,
6716            ..
6717        } = control
6718        else {
6719            unreachable!("phase validation leaves only reorg controls before records")
6720        };
6721        let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
6722            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6723        });
6724        let rollback_horizon_covers_ancestor = state
6725            .retained_canonical_history
6726            .first()
6727            .is_some_and(|oldest| oldest.number <= common_ancestor.number);
6728        if policy.requires_complete_rollback()
6729            && !exact_ancestor
6730            && !rollback_horizon_covers_ancestor
6731        {
6732            return Err(CanonicalSequenceError::IncompleteRollback {
6733                common_ancestor: common_ancestor.number,
6734                oldest_retained: rollback_oldest,
6735                kind: CanonicalRollbackKind::Explicit,
6736            });
6737        }
6738        let dropped = state
6739            .retained_canonical_history
6740            .iter()
6741            .copied()
6742            .filter(|block| block.number > common_ancestor.number)
6743            .collect::<Vec<_>>();
6744        state
6745            .retained_canonical_history
6746            .retain(|block| block.number <= common_ancestor.number);
6747        upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
6748        let mut enriched_ancestor = *common_ancestor;
6749        if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
6750            block.number == common_ancestor.number && block.hash == common_ancestor.hash
6751        }) {
6752            enrich_block_ref(&mut enriched_ancestor, retained);
6753        }
6754        if let Some(coverage) = state.coverage_head.as_ref()
6755            && coverage.number == common_ancestor.number
6756            && coverage.hash == common_ancestor.hash
6757        {
6758            enrich_block_ref(&mut enriched_ancestor, coverage);
6759        }
6760        upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
6761        state.coverage_head = Some(enriched_ancestor);
6762        clear_sequence_heads_above(&mut state, &enriched_ancestor);
6763        batch_dropped.record_explicit(common_ancestor, old_tip);
6764        batch_dropped.record_drained(&dropped);
6765        mutations.push(CanonicalSequenceMutation::Rewind {
6766            common_ancestor: Some(enriched_ancestor),
6767            dropped,
6768        });
6769    }
6770    let pre_record_state = state.clone();
6771    let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
6772
6773    for (record, scope) in records {
6774        if !scope.advances_canonical_state() {
6775            continue;
6776        }
6777        if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
6778            let incoming_dropped_block =
6779                resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
6780            validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
6781            validate_sequence_adjacent_parent_identity(
6782                &state,
6783                &incoming_dropped_block,
6784                "removed record",
6785            )?;
6786            let mut dropped_block = state
6787                .retained_canonical_history
6788                .iter()
6789                .find(|known| {
6790                    known.number == incoming_dropped_block.number
6791                        && known.hash == incoming_dropped_block.hash
6792                })
6793                .copied()
6794                .or_else(|| {
6795                    state.coverage_head.filter(|known| {
6796                        known.number == incoming_dropped_block.number
6797                            && known.hash == incoming_dropped_block.hash
6798                    })
6799                })
6800                .unwrap_or(incoming_dropped_block);
6801            enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
6802            validate_sequence_implicit_finality(&state, record, None)?;
6803            if dropped_block.number == 0 {
6804                return Err(ReactiveError::InvalidChainControl {
6805                    message: "a removed/reorged genesis block has no canonical parent anchor"
6806                        .into(),
6807                }
6808                .into());
6809            }
6810            let removed_identity = (dropped_block.number, dropped_block.hash);
6811            if let Some(previous_number) =
6812                removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
6813                && previous_number != dropped_block.number
6814            {
6815                return Err(ReactiveError::InvalidChainControl {
6816                    message: format!(
6817                        "removed hash {:?} is reused at heights {} and {}",
6818                        dropped_block.hash, previous_number, dropped_block.number
6819                    ),
6820                }
6821                .into());
6822            }
6823            if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
6824                if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
6825                    return Err(ReactiveError::InvalidChainControl {
6826                        message: format!(
6827                            "duplicate removed block {}:{:?} carries conflicting metadata",
6828                            dropped_block.number, dropped_block.hash
6829                        ),
6830                    }
6831                    .into());
6832                }
6833                enrich_block_ref(previous, &dropped_block);
6834            } else {
6835                removed_assertions.insert(removed_identity, dropped_block);
6836            }
6837            if asserted_blocks
6838                .get(&dropped_block.number)
6839                .is_some_and(|asserted| asserted.hash == dropped_block.hash)
6840            {
6841                return Err(ReactiveError::InvalidChainControl {
6842                    message: format!(
6843                        "removed block {}:{:?} is asserted canonical by the same envelope",
6844                        dropped_block.number, dropped_block.hash
6845                    ),
6846                }
6847                .into());
6848            }
6849            if batch_dropped.contains(&dropped_block) {
6850                continue;
6851            }
6852            if let Some(index) = state.retained_canonical_history.iter().position(|block| {
6853                block.number == dropped_block.number && block.hash == dropped_block.hash
6854            }) {
6855                let dropped = state.retained_canonical_history.split_off(index);
6856                batch_dropped.record_drained(&dropped);
6857                let ancestor_number = dropped_block
6858                    .number
6859                    .checked_sub(1)
6860                    .expect("genesis removal was rejected above");
6861                let retained_anchor = state
6862                    .retained_canonical_history
6863                    .iter()
6864                    .rev()
6865                    .find(|head| head.number == ancestor_number)
6866                    .copied();
6867                let authenticated_anchor = retained_anchor
6868                    .or_else(|| {
6869                        dropped_block.parent_hash.map(|hash| BlockRef {
6870                            number: ancestor_number,
6871                            hash,
6872                            parent_hash: None,
6873                            timestamp: None,
6874                        })
6875                    })
6876                    .or_else(|| {
6877                        state
6878                            .finalized_head
6879                            .filter(|head| head.number == ancestor_number)
6880                    });
6881                let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
6882                    for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
6883                        .into_iter()
6884                        .flatten()
6885                    {
6886                        if head.number == anchor.number && head.hash == anchor.hash {
6887                            enrich_block_ref(&mut anchor, head);
6888                        }
6889                    }
6890                    anchor
6891                });
6892                required_reorg_anchor = Some(RequiredReorgAnchor {
6893                    number: ancestor_number,
6894                    block: authenticated_anchor,
6895                    permits_missing_child_parent: retained_anchor.is_some(),
6896                    must_be_consumed: authenticated_anchor.is_none()
6897                        && state.retained_canonical_history.is_empty(),
6898                });
6899                state.coverage_head = authenticated_anchor
6900                    .or_else(|| state.retained_canonical_history.last().copied());
6901                if let Some(head) = state.coverage_head {
6902                    clear_sequence_heads_above(&mut state, &head);
6903                } else {
6904                    state.safe_head = None;
6905                    state.finalized_head = None;
6906                }
6907                mutations.push(CanonicalSequenceMutation::Rewind {
6908                    common_ancestor: state.coverage_head,
6909                    dropped,
6910                });
6911            } else {
6912                let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
6913                    block.number == dropped_block.number && block.hash != dropped_block.hash
6914                }) || state.coverage_head.is_some_and(|head| {
6915                    head.number == dropped_block.number && head.hash != dropped_block.hash
6916                });
6917                if !replacement_is_known {
6918                    // Ordinary runtime ingestion deliberately keeps an unknown
6919                    // deep removal observable and lets the recovery path
6920                    // degrade health. With no exact retained rollback proof,
6921                    // this validator must not fabricate a new canonical head.
6922                    if policy.requires_complete_rollback() {
6923                        return Err(CanonicalSequenceError::IncompleteRollback {
6924                            common_ancestor: dropped_block
6925                                .number
6926                                .checked_sub(1)
6927                                .expect("genesis removal was rejected above"),
6928                            oldest_retained: rollback_oldest,
6929                            kind: CanonicalRollbackKind::Removed,
6930                        });
6931                    }
6932                    continue;
6933                }
6934            }
6935            continue;
6936        }
6937
6938        let Some(context_block) = canonical_record_block(record) else {
6939            continue;
6940        };
6941        let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
6942        if post_record_controls
6943            .iter()
6944            .filter_map(canonical_coverage_control_block)
6945            .any(|asserted| {
6946                asserted.number == incoming_block.number
6947                    && asserted.hash == incoming_block.hash
6948                    && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
6949                    && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
6950                        || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
6951            })
6952        {
6953            record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
6954        }
6955        let mut resolved_block = incoming_block;
6956        if let Some(asserted) = asserted_blocks
6957            .get(&incoming_block.number)
6958            .filter(|asserted| asserted.hash == incoming_block.hash)
6959        {
6960            if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
6961                return Err(ReactiveError::InvalidChainControl {
6962                    message: format!(
6963                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6964                        incoming_block.number, incoming_block.hash
6965                    ),
6966                }
6967                .into());
6968            }
6969            enrich_block_ref(&mut resolved_block, asserted);
6970        }
6971        for asserted in post_record_controls
6972            .iter()
6973            .filter_map(chain_control_canonical_assertion)
6974            .filter(|asserted| {
6975                asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
6976            })
6977        {
6978            if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
6979                return Err(ReactiveError::InvalidChainControl {
6980                    message: format!(
6981                        "canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
6982                        incoming_block.number, incoming_block.hash
6983                    ),
6984                }
6985                .into());
6986            }
6987            enrich_block_ref(&mut resolved_block, asserted);
6988        }
6989        let replacement_anchor =
6990            required_reorg_anchor.filter(|required| resolved_block.number > required.number);
6991        if resolved_block.parent_hash.is_none()
6992            && replacement_anchor.is_some_and(|anchor| {
6993                anchor.permits_missing_child_parent
6994                    && anchor.number.checked_add(1) == Some(resolved_block.number)
6995            })
6996        {
6997            resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
6998        }
6999        let block = &resolved_block;
7000        if removed_assertions.contains_key(&(block.number, block.hash)) {
7001            return Err(ReactiveError::InvalidChainControl {
7002                message: format!(
7003                    "canonical block {}:{:?} is also removed by the same envelope",
7004                    block.number, block.hash
7005                ),
7006            }
7007            .into());
7008        }
7009        if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7010            && *removed_number != block.number
7011        {
7012            return Err(ReactiveError::InvalidChainControl {
7013                message: format!(
7014                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7015                    block.hash, block.number, removed_number
7016                ),
7017            }
7018            .into());
7019        }
7020        let replacement_proven_by_removal =
7021            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7022        if replacement_anchor.is_some() {
7023            required_reorg_anchor = None;
7024        }
7025        validate_sequence_matching_metadata(&state, block, "canonical record")?;
7026        validate_sequence_implicit_finality(&state, record, Some(block))?;
7027        let implicit_replacement_requires_history = if replacement_proven_by_removal {
7028            false
7029        } else {
7030            sequence_implicit_replacement_requires_history(&state, block, policy)?
7031        };
7032        if implicit_replacement_requires_history && policy.requires_complete_rollback() {
7033            return Err(CanonicalSequenceError::IncompleteRollback {
7034                common_ancestor: block.number.saturating_sub(1),
7035                oldest_retained: rollback_oldest,
7036                kind: CanonicalRollbackKind::ImplicitParent,
7037            });
7038        }
7039        assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
7040        let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7041            anchor.permits_missing_child_parent
7042                && anchor.number.checked_add(1) == Some(block.number)
7043        });
7044        if let Some(rewind) =
7045            apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7046        {
7047            mutations.push(CanonicalSequenceMutation::Rewind {
7048                common_ancestor: rewind.common_ancestor,
7049                dropped: rewind.dropped,
7050            });
7051        }
7052        mutations.push(CanonicalSequenceMutation::Canonical(*block));
7053    }
7054
7055    for control in post_record_controls {
7056        if let Some(block) = chain_control_canonical_assertion(control)
7057            && removed_assertions.contains_key(&(block.number, block.hash))
7058        {
7059            return Err(ReactiveError::InvalidChainControl {
7060                message: format!(
7061                    "canonical block {}:{:?} is also removed by the same envelope",
7062                    block.number, block.hash
7063                ),
7064            }
7065            .into());
7066        }
7067        if let Some(block) = chain_control_canonical_assertion(control)
7068            && let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
7069            && *removed_number != block.number
7070        {
7071            return Err(ReactiveError::InvalidChainControl {
7072                message: format!(
7073                    "canonical hash {:?} at height {} is removed at height {} by the same envelope",
7074                    block.hash, block.number, removed_number
7075                ),
7076            }
7077            .into());
7078        }
7079        let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
7080            required_reorg_anchor.filter(|required| block.number > required.number)
7081        });
7082        if let Some(block) = canonical_coverage_control_block(control) {
7083            validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
7084            if replacement_anchor.is_some() {
7085                required_reorg_anchor = None;
7086            }
7087        }
7088        assert_chain_control_identities(&mut asserted_blocks, control)?;
7089        let preserves_record_proof =
7090            canonical_coverage_control_block(control).is_some_and(|block| {
7091                record_proof_control_identities.contains(&(block.number, block.hash))
7092            });
7093        if policy.normalizes_coverage()
7094            && !preserves_record_proof
7095            && let Some(block) = canonical_coverage_control_block(control)
7096            && state
7097                .coverage_head
7098                .is_some_and(|head| block.number <= head.number)
7099        {
7100            let is_equal_coverage = state
7101                .coverage_head
7102                .is_some_and(|head| block.number == head.number);
7103            let known = state
7104                .coverage_head
7105                .as_ref()
7106                .filter(|head| head.number == block.number && head.hash == block.hash)
7107                .or_else(|| {
7108                    state
7109                        .retained_canonical_history
7110                        .iter()
7111                        .find(|entry| entry.number == block.number && entry.hash == block.hash)
7112                });
7113            if let Some(known) = known
7114                && optional_block_refs_are_compatible(Some(known), Some(block))
7115                && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
7116            {
7117                if let ChainControl::Barrier { id, .. } = control {
7118                    normalized_chain_controls.push(ChainControl::Barrier {
7119                        id: id.clone(),
7120                        block: None,
7121                    });
7122                }
7123                continue;
7124            }
7125        }
7126        validate_sequence_control(&state, control)?;
7127        normalized_chain_controls.push(control.clone());
7128        match control {
7129            ChainControl::Safe(block) => {
7130                set_or_enrich_block_ref(&mut state.safe_head, block);
7131                mutations.push(CanonicalSequenceMutation::Safe(
7132                    state.safe_head.expect("safe head was just installed"),
7133                ));
7134            }
7135            ChainControl::Finalized(block) => {
7136                set_or_enrich_block_ref(&mut state.finalized_head, block);
7137                mutations.push(CanonicalSequenceMutation::Finalized(
7138                    state
7139                        .finalized_head
7140                        .expect("finalized head was just installed"),
7141                ));
7142            }
7143            ChainControl::CanonicalProgress(block)
7144            | ChainControl::Barrier {
7145                block: Some(block), ..
7146            } => {
7147                let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
7148                    anchor.permits_missing_child_parent
7149                        && anchor.number.checked_add(1) == Some(block.number)
7150                }) || (replacement_anchor.is_none()
7151                    && block.parent_hash.is_none()
7152                    && state
7153                        .coverage_head
7154                        .is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
7155                if let Some(rewind) =
7156                    apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
7157                {
7158                    mutations.push(CanonicalSequenceMutation::Rewind {
7159                        common_ancestor: rewind.common_ancestor,
7160                        dropped: rewind.dropped,
7161                    });
7162                }
7163                mutations.push(CanonicalSequenceMutation::Canonical(*block));
7164            }
7165            ChainControl::Barrier { block: None, .. } => {}
7166            ChainControl::Reorg { .. } => {
7167                unreachable!("phase validation excludes post-record reorg controls")
7168            }
7169        }
7170    }
7171
7172    if let Some(required) = required_reorg_anchor
7173        && required.must_be_consumed
7174        && policy.requires_complete_rollback()
7175    {
7176        return Err(CanonicalSequenceError::IncompleteRollback {
7177            common_ancestor: required.number,
7178            oldest_retained: rollback_oldest,
7179            kind: CanonicalRollbackKind::MissingReplacement,
7180        });
7181    }
7182
7183    validate_canonical_sequence_snapshot(&state)?;
7184    Ok(CanonicalSequenceValidation {
7185        pre_record_state,
7186        next_state: state,
7187        mutations,
7188        normalized_chain_controls,
7189    })
7190}
7191
7192fn validate_canonical_sequence_snapshot(
7193    state: &CanonicalSequenceState,
7194) -> Result<(), ReactiveError> {
7195    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7196    let supplied_blocks = state
7197        .retained_canonical_history
7198        .iter()
7199        .chain(state.coverage_head.iter())
7200        .chain(state.safe_head.iter())
7201        .chain(state.finalized_head.iter())
7202        .collect::<Vec<_>>();
7203    validate_known_parent_hash_heights(&supplied_blocks)?;
7204    let mut prior = None::<BlockRef>;
7205    for block in &state.retained_canonical_history {
7206        if let Some(previous) = prior {
7207            if block.number < previous.number {
7208                return Err(invalid(
7209                    "retained canonical history is not ordered by block number".into(),
7210                ));
7211            }
7212            if block.number == previous.number {
7213                let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
7214                {
7215                    "duplicate"
7216                } else {
7217                    "conflicting"
7218                };
7219                return Err(invalid(format!(
7220                    "retained canonical history contains {qualifier} identities at block {}",
7221                    block.number
7222                )));
7223            }
7224            if previous.number.checked_add(1) == Some(block.number)
7225                && block.parent_hash.is_some()
7226                && block.parent_hash != Some(previous.hash)
7227            {
7228                return Err(invalid(format!(
7229                    "adjacent retained block {}:{:?} does not descend from {}:{:?}",
7230                    block.number, block.hash, previous.number, previous.hash
7231                )));
7232            }
7233        }
7234        prior = Some(*block);
7235    }
7236    if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
7237        return Err(invalid(
7238            "retained canonical history requires an authoritative coverage head".into(),
7239        ));
7240    }
7241    if let Some(head) = state.coverage_head.as_ref() {
7242        if let Some(retained) = state
7243            .retained_canonical_history
7244            .iter()
7245            .find(|entry| entry.number == head.number)
7246            && !optional_block_refs_are_compatible(Some(retained), Some(head))
7247        {
7248            return Err(invalid(format!(
7249                "coverage head {}:{:?} conflicts with retained identity {:?}",
7250                head.number, head.hash, retained
7251            )));
7252        }
7253        if state
7254            .retained_canonical_history
7255            .last()
7256            .is_some_and(|retained| retained.number > head.number)
7257        {
7258            return Err(invalid(
7259                "retained canonical history advances beyond the coverage head".into(),
7260            ));
7261        }
7262        if let Some(retained) = state.retained_canonical_history.last()
7263            && retained.number.checked_add(1) == Some(head.number)
7264            && head.parent_hash.is_some()
7265            && head.parent_hash != Some(retained.hash)
7266        {
7267            return Err(invalid(format!(
7268                "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
7269                head.number, head.hash, retained.number, retained.hash
7270            )));
7271        }
7272    }
7273    if let Some(safe) = state.safe_head.as_ref() {
7274        validate_sequence_known_identity(state, safe, "safe")?;
7275        validate_sequence_head_within_coverage(state, safe, "safe")?;
7276        validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
7277    }
7278    if let Some(finalized) = state.finalized_head.as_ref() {
7279        validate_sequence_known_identity(state, finalized, "finalized")?;
7280        validate_sequence_head_within_coverage(state, finalized, "finalized")?;
7281        validate_coverage_descends_from_adjacent_head(
7282            state.coverage_head.as_ref(),
7283            finalized,
7284            "finalized",
7285        )?;
7286    }
7287    validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
7288    if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
7289        && (finalized.number > safe.number
7290            || (finalized.number == safe.number && finalized.hash != safe.hash))
7291    {
7292        return Err(invalid(
7293            "finalized head cannot advance beyond or conflict with safe head".into(),
7294        ));
7295    }
7296    Ok(())
7297}
7298
7299fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
7300    let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
7301    let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
7302    for block in blocks.iter().copied() {
7303        if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
7304            && previous_height != block.number
7305        {
7306            return Err(ReactiveError::InvalidChainControl {
7307                message: format!(
7308                    "canonical hash {:?} is reused at heights {} and {}",
7309                    block.hash, previous_height, block.number
7310                ),
7311            });
7312        }
7313        if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
7314            if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
7315                return Err(ReactiveError::InvalidChainControl {
7316                    message: format!(
7317                        "canonical aliases at height {} carry conflicting identities or metadata",
7318                        block.number
7319                    ),
7320                });
7321            }
7322            enrich_block_ref(resolved, block);
7323        } else {
7324            resolved_by_height.insert(block.number, *block);
7325        }
7326    }
7327    for child in resolved_by_height.values() {
7328        let Some(parent_hash) = child.parent_hash else {
7329            continue;
7330        };
7331        if let Some(parent_number) = heights_by_hash.get(&parent_hash)
7332            && parent_number.checked_add(1) != Some(child.number)
7333        {
7334            return Err(ReactiveError::InvalidChainControl {
7335                message: format!(
7336                    "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7337                    child.number, child.hash, parent_hash, parent_number
7338                ),
7339            });
7340        }
7341        if let Some(parent_number) = child.number.checked_sub(1)
7342            && let Some(parent) = resolved_by_height.get(&parent_number)
7343            && parent.hash != parent_hash
7344        {
7345            return Err(ReactiveError::InvalidChainControl {
7346                message: format!(
7347                    "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
7348                    child.number, child.hash, parent.number, parent.hash
7349                ),
7350            });
7351        }
7352    }
7353    Ok(())
7354}
7355
7356fn validate_coverage_descends_from_adjacent_head(
7357    coverage: Option<&BlockRef>,
7358    head: &BlockRef,
7359    label: &str,
7360) -> Result<(), ReactiveError> {
7361    let Some(coverage) = coverage else {
7362        return Ok(());
7363    };
7364    if head.number.checked_add(1) == Some(coverage.number)
7365        && coverage
7366            .parent_hash
7367            .is_some_and(|parent| parent != head.hash)
7368    {
7369        return Err(ReactiveError::InvalidChainControl {
7370            message: format!(
7371                "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
7372                coverage.number, coverage.hash, head.number, head.hash
7373            ),
7374        });
7375    }
7376    Ok(())
7377}
7378
7379fn validate_sequence_control(
7380    state: &CanonicalSequenceState,
7381    control: &ChainControl,
7382) -> Result<(), ReactiveError> {
7383    let invalid = |message: String| ReactiveError::InvalidChainControl { message };
7384    match control {
7385        ChainControl::Safe(block) => {
7386            validate_sequence_known_identity(state, block, "safe")?;
7387            validate_sequence_head_within_coverage(state, block, "safe")?;
7388            if let Some(current) = state.safe_head.as_ref()
7389                && (block.number < current.number
7390                    || (block.number == current.number
7391                        && (block.hash != current.hash
7392                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7393            {
7394                return Err(invalid(format!(
7395                    "safe head {}:{:?} conflicts with current {}:{:?}",
7396                    block.number, block.hash, current.number, current.hash
7397                )));
7398            }
7399            if let Some(finalized) = state.finalized_head.as_ref()
7400                && (block.number < finalized.number
7401                    || (block.number == finalized.number && block.hash != finalized.hash))
7402            {
7403                return Err(invalid(
7404                    "safe head cannot precede or conflict with finalized head".into(),
7405                ));
7406            }
7407            validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
7408        }
7409        ChainControl::Finalized(block) => {
7410            validate_sequence_known_identity(state, block, "finalized")?;
7411            validate_sequence_head_within_coverage(state, block, "finalized")?;
7412            if let Some(current) = state.finalized_head.as_ref()
7413                && (block.number < current.number
7414                    || (block.number == current.number
7415                        && (block.hash != current.hash
7416                            || !optional_block_refs_are_compatible(Some(block), Some(current)))))
7417            {
7418                return Err(invalid(format!(
7419                    "finalized head {}:{:?} conflicts with current {}:{:?}",
7420                    block.number, block.hash, current.number, current.hash
7421                )));
7422            }
7423            if let Some(safe) = state.safe_head.as_ref()
7424                && (block.number > safe.number
7425                    || (block.number == safe.number && block.hash != safe.hash))
7426            {
7427                return Err(invalid(
7428                    "finalized head cannot advance beyond or conflict with safe head".into(),
7429                ));
7430            }
7431            validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
7432        }
7433        ChainControl::CanonicalProgress(block)
7434        | ChainControl::Barrier {
7435            block: Some(block), ..
7436        } => {
7437            validate_sequence_known_identity(state, block, "canonical coverage")?;
7438            if let Some(current) = state.coverage_head.as_ref()
7439                && (block.number < current.number
7440                    || (block.number == current.number && block.hash != current.hash))
7441            {
7442                return Err(invalid(format!(
7443                    "canonical coverage {}:{:?} conflicts with current {}:{:?}",
7444                    block.number, block.hash, current.number, current.hash
7445                )));
7446            }
7447            if let Some(current) = state.coverage_head.as_ref()
7448                && current.number.checked_add(1) == Some(block.number)
7449                && block.parent_hash.is_some()
7450                && block.parent_hash != Some(current.hash)
7451            {
7452                return Err(invalid(format!(
7453                    "canonical coverage {}:{:?} does not descend from current {}:{:?}",
7454                    block.number, block.hash, current.number, current.hash
7455                )));
7456            }
7457        }
7458        ChainControl::Barrier { block: None, .. } => {}
7459        ChainControl::Reorg {
7460            common_ancestor,
7461            old_tip,
7462            new_tip,
7463        } => {
7464            validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
7465            validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
7466            validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
7467            validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
7468            validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
7469            validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
7470            validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
7471            if let Some(current) = state.coverage_head.as_ref()
7472                && (old_tip.number != current.number
7473                    || old_tip.hash != current.hash
7474                    || !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
7475            {
7476                return Err(invalid(format!(
7477                    "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
7478                    old_tip.number, old_tip.hash, current.number, current.hash
7479                )));
7480            }
7481            if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
7482                return Err(invalid(
7483                    "reorg common ancestor cannot be above either branch tip".into(),
7484                ));
7485            }
7486            if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
7487            {
7488                return Err(invalid(
7489                    "reorg must replace non-empty old and new branches above the common ancestor"
7490                        .into(),
7491                ));
7492            }
7493            if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
7494                return Err(invalid(
7495                    "reorg old and new tips cannot have the same canonical identity".into(),
7496                ));
7497            }
7498            for (label, tip) in [("old", old_tip), ("new", new_tip)] {
7499                if common_ancestor.number.checked_add(1) == Some(tip.number)
7500                    && tip.parent_hash != Some(common_ancestor.hash)
7501                {
7502                    return Err(invalid(format!(
7503                        "reorg {label} tip does not descend from the common ancestor"
7504                    )));
7505                }
7506            }
7507            if let Some(finalized) = state.finalized_head.as_ref()
7508                && (common_ancestor.number < finalized.number
7509                    || (common_ancestor.number == finalized.number
7510                        && common_ancestor.hash != finalized.hash))
7511            {
7512                return Err(invalid(
7513                    "reorg would cross or conflict with the finalized head".into(),
7514                ));
7515            }
7516        }
7517    }
7518    Ok(())
7519}
7520
7521fn validate_sequence_known_identity(
7522    state: &CanonicalSequenceState,
7523    block: &BlockRef,
7524    label: &str,
7525) -> Result<(), ReactiveError> {
7526    validate_sequence_known_hash_height(state, block, label)?;
7527    validate_sequence_known_parent_height(state, block, label)?;
7528    let known = state
7529        .coverage_head
7530        .as_ref()
7531        .filter(|head| head.number == block.number)
7532        .or_else(|| {
7533            state
7534                .retained_canonical_history
7535                .iter()
7536                .find(|entry| entry.number == block.number)
7537        });
7538    if let Some(known) = known
7539        && !optional_block_refs_are_compatible(Some(known), Some(block))
7540    {
7541        return Err(ReactiveError::InvalidChainControl {
7542            message: format!(
7543                "{label} block {}:{:?} conflicts with known canonical block {:?}",
7544                block.number, block.hash, known
7545            ),
7546        });
7547    }
7548    Ok(())
7549}
7550
7551fn validate_sequence_known_parent_height(
7552    state: &CanonicalSequenceState,
7553    block: &BlockRef,
7554    label: &str,
7555) -> Result<(), ReactiveError> {
7556    let Some(parent_hash) = block.parent_hash else {
7557        return Ok(());
7558    };
7559    let known_parent = state
7560        .retained_canonical_history
7561        .iter()
7562        .chain(state.coverage_head.iter())
7563        .chain(state.safe_head.iter())
7564        .chain(state.finalized_head.iter())
7565        .find(|known| known.hash == parent_hash);
7566    if let Some(parent) = known_parent
7567        && parent.number.checked_add(1) != Some(block.number)
7568    {
7569        return Err(ReactiveError::InvalidChainControl {
7570            message: format!(
7571                "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
7572                block.number, block.hash, parent.hash, parent.number
7573            ),
7574        });
7575    }
7576    Ok(())
7577}
7578
7579fn validate_sequence_head_within_coverage(
7580    state: &CanonicalSequenceState,
7581    block: &BlockRef,
7582    label: &str,
7583) -> Result<(), ReactiveError> {
7584    let Some(coverage) = state.coverage_head.as_ref() else {
7585        return Err(ReactiveError::InvalidChainControl {
7586            message: format!("{label} head requires an authoritative coverage head"),
7587        });
7588    };
7589    if block.number > coverage.number
7590        || (block.number == coverage.number
7591            && !optional_block_refs_are_compatible(Some(block), Some(coverage)))
7592    {
7593        return Err(ReactiveError::InvalidChainControl {
7594            message: format!(
7595                "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
7596                block.number, block.hash, coverage.number, coverage.hash
7597            ),
7598        });
7599    }
7600    Ok(())
7601}
7602
7603fn validate_sequence_matching_metadata(
7604    state: &CanonicalSequenceState,
7605    block: &BlockRef,
7606    label: &str,
7607) -> Result<(), ReactiveError> {
7608    validate_sequence_known_hash_height(state, block, label)?;
7609    validate_sequence_known_parent_height(state, block, label)?;
7610    let known = state
7611        .coverage_head
7612        .as_ref()
7613        .filter(|head| head.number == block.number && head.hash == block.hash)
7614        .or_else(|| {
7615            state
7616                .retained_canonical_history
7617                .iter()
7618                .find(|entry| entry.number == block.number && entry.hash == block.hash)
7619        });
7620    if let Some(known) = known
7621        && !optional_block_refs_are_compatible(Some(known), Some(block))
7622    {
7623        return Err(ReactiveError::InvalidChainControl {
7624            message: format!(
7625                "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
7626                block.number, block.hash, known
7627            ),
7628        });
7629    }
7630    Ok(())
7631}
7632
7633fn validate_sequence_known_hash_height(
7634    state: &CanonicalSequenceState,
7635    block: &BlockRef,
7636    label: &str,
7637) -> Result<(), ReactiveError> {
7638    let known = state
7639        .retained_canonical_history
7640        .iter()
7641        .chain(state.coverage_head.iter())
7642        .chain(state.safe_head.iter())
7643        .chain(state.finalized_head.iter())
7644        .find(|known| known.hash == block.hash);
7645    if let Some(known) = known
7646        && known.number != block.number
7647    {
7648        return Err(ReactiveError::InvalidChainControl {
7649            message: format!(
7650                "{label} block {}:{:?} reuses a canonical hash already known at height {}",
7651                block.number, block.hash, known.number
7652            ),
7653        });
7654    }
7655    Ok(())
7656}
7657
7658fn validate_reorg_ancestor_against_retained_branch(
7659    state: &CanonicalSequenceState,
7660    ancestor: &BlockRef,
7661) -> Result<(), ReactiveError> {
7662    let adjacent_number = ancestor.number.checked_add(1);
7663    for retained in state
7664        .retained_canonical_history
7665        .iter()
7666        .chain(state.coverage_head.iter())
7667        .chain(state.safe_head.iter())
7668        .chain(state.finalized_head.iter())
7669    {
7670        if Some(retained.number) == adjacent_number
7671            && retained
7672                .parent_hash
7673                .is_some_and(|parent| parent != ancestor.hash)
7674        {
7675            return Err(ReactiveError::InvalidChainControl {
7676                message: format!(
7677                    "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
7678                    ancestor.number,
7679                    ancestor.hash,
7680                    retained.number,
7681                    retained.hash,
7682                    retained.parent_hash
7683                ),
7684            });
7685        }
7686        if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
7687            return Err(ReactiveError::InvalidChainControl {
7688                message: format!(
7689                    "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
7690                    ancestor.number, ancestor.hash, retained.number, retained.hash
7691                ),
7692            });
7693        }
7694    }
7695    Ok(())
7696}
7697
7698fn validate_sequence_adjacent_parent_identity(
7699    state: &CanonicalSequenceState,
7700    block: &BlockRef,
7701    label: &str,
7702) -> Result<(), ReactiveError> {
7703    let Some(parent_hash) = block.parent_hash else {
7704        return Ok(());
7705    };
7706    let Some(parent_number) = block.number.checked_sub(1) else {
7707        return Ok(());
7708    };
7709    let known_parent = state
7710        .retained_canonical_history
7711        .iter()
7712        .chain(state.coverage_head.iter())
7713        .chain(state.safe_head.iter())
7714        .chain(state.finalized_head.iter())
7715        .find(|known| known.number == parent_number);
7716    if let Some(known_parent) = known_parent
7717        && known_parent.hash != parent_hash
7718    {
7719        return Err(ReactiveError::InvalidChainControl {
7720            message: format!(
7721                "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7722                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7723            ),
7724        });
7725    }
7726    Ok(())
7727}
7728
7729fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
7730    state
7731        .coverage_head
7732        .iter()
7733        .chain(state.retained_canonical_history.iter())
7734        .filter(|known| known.number == incoming.number && known.hash == incoming.hash)
7735        .any(|known| {
7736            (known.parent_hash.is_none() && incoming.parent_hash.is_some())
7737                || (known.timestamp.is_none() && incoming.timestamp.is_some())
7738        })
7739}
7740
7741fn validate_sequence_implicit_finality<N: Network>(
7742    state: &CanonicalSequenceState,
7743    record: &ReactiveInputRecord<N>,
7744    resolved_canonical_block: Option<&BlockRef>,
7745) -> Result<(), ReactiveError> {
7746    let Some(finalized) = state.finalized_head.as_ref() else {
7747        return Ok(());
7748    };
7749    if let Some((dropped, _)) = reorg_signal_block(record) {
7750        if dropped.number <= finalized.number {
7751            return Err(ReactiveError::InvalidChainControl {
7752                message: format!(
7753                    "implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
7754                    dropped.number, dropped.hash, finalized.number, finalized.hash
7755                ),
7756            });
7757        }
7758        return Ok(());
7759    }
7760    let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
7761        return Ok(());
7762    };
7763    let Some(latest) = state.coverage_head.as_ref() else {
7764        return Ok(());
7765    };
7766    if (block.number == latest.number && block.hash == latest.hash)
7767        || state
7768            .retained_canonical_history
7769            .iter()
7770            .any(|entry| entry.number == block.number && entry.hash == block.hash)
7771        || (latest.number.checked_add(1) == Some(block.number)
7772            && block.parent_hash == Some(latest.hash))
7773        || latest
7774            .number
7775            .checked_add(1)
7776            .is_some_and(|next| block.number > next)
7777    {
7778        return Ok(());
7779    }
7780    let crosses_finalized = if block.number <= finalized.number {
7781        true
7782    } else if let Some(parent_hash) = block.parent_hash {
7783        if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
7784            false
7785        } else if let Some(parent_index) =
7786            state.retained_canonical_history.iter().rposition(|entry| {
7787                entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7788            })
7789        {
7790            state
7791                .retained_canonical_history
7792                .iter()
7793                .skip(parent_index + 1)
7794                .any(|entry| entry.number <= finalized.number)
7795        } else {
7796            true
7797        }
7798    } else {
7799        true
7800    };
7801    if crosses_finalized {
7802        return Err(ReactiveError::InvalidChainControl {
7803            message: format!(
7804                "canonical input {}:{:?} would replace finalized head {}:{:?}",
7805                block.number, block.hash, finalized.number, finalized.hash
7806            ),
7807        });
7808    }
7809    Ok(())
7810}
7811
7812fn validate_required_reorg_anchor(
7813    required: Option<RequiredReorgAnchor>,
7814    block: &BlockRef,
7815) -> Result<(), ReactiveError> {
7816    let Some(required) = required else {
7817        return Ok(());
7818    };
7819    let ancestor_hash = required.hash();
7820    let restores_ancestor =
7821        block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
7822    let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
7823        && ancestor_hash.is_some()
7824        && (block.parent_hash == ancestor_hash
7825            || (block.parent_hash.is_none() && required.permits_missing_child_parent));
7826    if restores_ancestor || replaces_removed_child {
7827        return Ok(());
7828    }
7829    Err(ReactiveError::InvalidChainControl {
7830        message: format!(
7831            "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
7832            block.number, block.hash, required.number
7833        ),
7834    })
7835}
7836
7837fn validate_replacement_reorg_anchor(
7838    required: Option<RequiredReorgAnchor>,
7839    block: &BlockRef,
7840    policy: CanonicalSequenceValidationPolicy,
7841    oldest_retained: Option<u64>,
7842) -> Result<bool, CanonicalSequenceError> {
7843    let Some(required) = required else {
7844        return Ok(false);
7845    };
7846    match validate_required_reorg_anchor(Some(required), block) {
7847        Ok(()) => Ok(true),
7848        Err(error) if required.block.is_some() => Err(error.into()),
7849        Err(_) if policy.requires_complete_rollback() => {
7850            Err(CanonicalSequenceError::IncompleteRollback {
7851                common_ancestor: required.number,
7852                oldest_retained,
7853                kind: CanonicalRollbackKind::MissingReplacement,
7854            })
7855        }
7856        Err(_) => Ok(false),
7857    }
7858}
7859
7860fn apply_sequence_canonical_block(
7861    state: &mut CanonicalSequenceState,
7862    block: &BlockRef,
7863    allow_parentless_adjacent_extension: bool,
7864) -> Result<Option<SequenceRewind>, ReactiveError> {
7865    let latest = state.coverage_head;
7866    let already_known = state
7867        .retained_canonical_history
7868        .iter()
7869        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7870    let repeats_tip =
7871        latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
7872    let extends_tip = latest.is_some_and(|head| {
7873        head.number.checked_add(1) == Some(block.number)
7874            && (block.parent_hash == Some(head.hash)
7875                || (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
7876    });
7877    let forward_gap = latest.is_some_and(|head| {
7878        head.number
7879            .checked_add(1)
7880            .is_some_and(|next| block.number > next)
7881    });
7882    let mut rewind = None;
7883
7884    if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
7885        let retained_parent = block.parent_hash.and_then(|parent_hash| {
7886            state
7887                .retained_canonical_history
7888                .iter()
7889                .rposition(|entry| {
7890                    entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7891                })
7892                .map(|index| (index, state.retained_canonical_history[index]))
7893        });
7894        let finalized_parent = block.parent_hash.and_then(|parent_hash| {
7895            state.finalized_head.filter(|finalized| {
7896                finalized.number.checked_add(1) == Some(block.number)
7897                    && finalized.hash == parent_hash
7898            })
7899        });
7900        let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
7901            let dropped = state.retained_canonical_history.split_off(parent_index + 1);
7902            (Some(parent), dropped)
7903        } else if let Some(finalized) = finalized_parent {
7904            let dropped = state
7905                .retained_canonical_history
7906                .iter()
7907                .position(|entry| entry.number > finalized.number)
7908                .map_or_else(Vec::new, |index| {
7909                    state.retained_canonical_history.split_off(index)
7910                });
7911            (Some(finalized), dropped)
7912        } else {
7913            // The observable runtime policy may continue after an incomplete
7914            // rollback proof so it can degrade health and repair. The metadata
7915            // validator must nevertheless avoid claiming any old prefix is an
7916            // ancestor of the arriving branch: without the exact N-1 parent,
7917            // no retained identity is authenticated.
7918            (None, std::mem::take(&mut state.retained_canonical_history))
7919        };
7920        state.coverage_head = common_ancestor;
7921        if let Some(common_ancestor) = common_ancestor {
7922            clear_sequence_heads_above(state, &common_ancestor);
7923        } else {
7924            state.safe_head = None;
7925            state.finalized_head = None;
7926        }
7927        rewind = Some(SequenceRewind {
7928            common_ancestor,
7929            dropped,
7930        });
7931    }
7932    upsert_sequence_history(&mut state.retained_canonical_history, block)?;
7933    advance_or_enrich_coverage(&mut state.coverage_head, block);
7934    Ok(rewind)
7935}
7936
7937fn sequence_implicit_replacement_requires_history(
7938    state: &CanonicalSequenceState,
7939    block: &BlockRef,
7940    policy: CanonicalSequenceValidationPolicy,
7941) -> Result<bool, ReactiveError> {
7942    let Some(latest) = state.coverage_head else {
7943        return Ok(false);
7944    };
7945    let already_known = state
7946        .retained_canonical_history
7947        .iter()
7948        .any(|entry| entry.number == block.number && entry.hash == block.hash);
7949    let repeats_tip = block.number == latest.number && block.hash == latest.hash;
7950    let extends_tip = latest.number.checked_add(1) == Some(block.number)
7951        && block.parent_hash == Some(latest.hash);
7952    let forward_gap = latest
7953        .number
7954        .checked_add(1)
7955        .is_some_and(|next| block.number > next);
7956    if already_known || repeats_tip || extends_tip || forward_gap {
7957        return Ok(false);
7958    }
7959    let Some(parent_hash) = block.parent_hash else {
7960        if policy.requires_complete_rollback() {
7961            return Err(ReactiveError::InvalidChainControl {
7962                message: format!(
7963                    "implicit canonical replacement {}:{:?} must identify its parent",
7964                    block.number, block.hash
7965                ),
7966            });
7967        }
7968        return Ok(true);
7969    };
7970    let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
7971        state
7972            .retained_canonical_history
7973            .iter()
7974            .chain(state.coverage_head.iter())
7975            .chain(state.safe_head.iter())
7976            .chain(state.finalized_head.iter())
7977            .find(|known| known.number == parent_number)
7978    });
7979    if let Some(known_parent) = known_adjacent_parent
7980        && known_parent.hash != parent_hash
7981        && policy.requires_complete_rollback()
7982    {
7983        return Err(ReactiveError::InvalidChainControl {
7984            message: format!(
7985                "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
7986                block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
7987            ),
7988        });
7989    }
7990    let retained_parent = state.retained_canonical_history.iter().any(|entry| {
7991        entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
7992    });
7993    let finalized_parent = state.finalized_head.is_some_and(|finalized| {
7994        finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
7995    });
7996    Ok(!retained_parent && !finalized_parent)
7997}
7998
7999fn upsert_sequence_history(
8000    history: &mut Vec<BlockRef>,
8001    block: &BlockRef,
8002) -> Result<(), ReactiveError> {
8003    if let Some(existing) = history
8004        .iter_mut()
8005        .find(|entry| entry.number == block.number)
8006    {
8007        if existing.hash != block.hash {
8008            return Err(ReactiveError::InvalidChainControl {
8009                message: format!(
8010                    "canonical block {}:{:?} conflicts with retained identity {:?}",
8011                    block.number, block.hash, existing
8012                ),
8013            });
8014        }
8015        if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
8016            return Err(ReactiveError::InvalidChainControl {
8017                message: format!(
8018                    "canonical block {}:{:?} carries conflicting retained metadata",
8019                    block.number, block.hash
8020                ),
8021            });
8022        }
8023        enrich_block_ref(existing, block);
8024    } else {
8025        history.push(*block);
8026        history.sort_by_key(|entry| entry.number);
8027    }
8028    Ok(())
8029}
8030
8031fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
8032    if state.safe_head.as_ref().is_some_and(|head| {
8033        head.number > ancestor.number
8034            || (head.number == ancestor.number && head.hash != ancestor.hash)
8035    }) {
8036        state.safe_head = None;
8037    }
8038    if state.finalized_head.as_ref().is_some_and(|head| {
8039        head.number > ancestor.number
8040            || (head.number == ancestor.number && head.hash != ancestor.hash)
8041    }) {
8042        state.finalized_head = None;
8043    }
8044}
8045
8046fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
8047    let split = controls
8048        .iter()
8049        .position(|control| !matches!(control, ChainControl::Reorg { .. }))
8050        .unwrap_or(controls.len());
8051    if controls[split..]
8052        .iter()
8053        .any(|control| matches!(control, ChainControl::Reorg { .. }))
8054    {
8055        return Err(ReactiveError::InvalidChainControl {
8056            message: "reorg controls must precede records and all post-record controls in a batch"
8057                .into(),
8058        });
8059    }
8060    Ok(split)
8061}
8062
8063fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
8064    match control {
8065        ChainControl::CanonicalProgress(block)
8066        | ChainControl::Barrier {
8067            block: Some(block), ..
8068        } => Some(block),
8069        ChainControl::Reorg { .. }
8070        | ChainControl::Safe(_)
8071        | ChainControl::Finalized(_)
8072        | ChainControl::Barrier { block: None, .. } => None,
8073    }
8074}
8075
8076fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
8077    match control {
8078        ChainControl::Safe(block)
8079        | ChainControl::Finalized(block)
8080        | ChainControl::CanonicalProgress(block)
8081        | ChainControl::Barrier {
8082            block: Some(block), ..
8083        } => Some(block),
8084        ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
8085    }
8086}
8087
8088fn assert_chain_control_identities(
8089    asserted_blocks: &mut HashMap<u64, BlockRef>,
8090    control: &ChainControl,
8091) -> Result<(), ReactiveError> {
8092    match control {
8093        ChainControl::Safe(block)
8094        | ChainControl::Finalized(block)
8095        | ChainControl::CanonicalProgress(block)
8096        | ChainControl::Barrier {
8097            block: Some(block), ..
8098        } => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
8099        ChainControl::Barrier { block: None, .. } => Ok(()),
8100        ChainControl::Reorg {
8101            common_ancestor,
8102            new_tip,
8103            ..
8104        } => {
8105            asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
8106            assert_canonical_block_identity(
8107                asserted_blocks,
8108                common_ancestor,
8109                "reorg common ancestor",
8110            )?;
8111            assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
8112        }
8113    }
8114}
8115
8116fn assert_canonical_block_identity(
8117    asserted_blocks: &mut HashMap<u64, BlockRef>,
8118    block: &BlockRef,
8119    label: &str,
8120) -> Result<(), ReactiveError> {
8121    for asserted in asserted_blocks.values() {
8122        if asserted.hash == block.hash && asserted.number != block.number {
8123            return Err(ReactiveError::InvalidChainControl {
8124                message: format!(
8125                    "{label} hash {:?} is already asserted at height {}, not {}",
8126                    block.hash, asserted.number, block.number
8127                ),
8128            });
8129        }
8130        if block
8131            .parent_hash
8132            .is_some_and(|parent| parent == asserted.hash)
8133            && asserted.number.checked_add(1) != Some(block.number)
8134        {
8135            return Err(ReactiveError::InvalidChainControl {
8136                message: format!(
8137                    "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
8138                    block.number, block.hash, asserted.hash, asserted.number
8139                ),
8140            });
8141        }
8142        if asserted
8143            .parent_hash
8144            .is_some_and(|parent| parent == block.hash)
8145            && block.number.checked_add(1) != Some(asserted.number)
8146        {
8147            return Err(ReactiveError::InvalidChainControl {
8148                message: format!(
8149                    "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
8150                    asserted.number, asserted.hash, block.hash, block.number
8151                ),
8152            });
8153        }
8154    }
8155    if let Some(known) = asserted_blocks.get_mut(&block.number) {
8156        if !optional_block_refs_are_compatible(Some(known), Some(block)) {
8157            return Err(ReactiveError::InvalidChainControl {
8158                message: format!(
8159                    "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
8160                    block.number, block.hash, known
8161                ),
8162            });
8163        }
8164        enrich_block_ref(known, block);
8165    } else {
8166        asserted_blocks.insert(block.number, *block);
8167    }
8168    Ok(())
8169}
8170
8171fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8172    match current {
8173        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8174            enrich_block_ref(current, incoming);
8175        }
8176        _ => *current = Some(*incoming),
8177    }
8178}
8179
8180fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
8181    match current {
8182        Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
8183            enrich_block_ref(current, incoming);
8184        }
8185        Some(current) if current.number >= incoming.number => {}
8186        _ => *current = Some(*incoming),
8187    }
8188}
8189
8190fn validate_adjacent_finality(
8191    finalized: Option<&BlockRef>,
8192    safe: Option<&BlockRef>,
8193) -> Result<(), ReactiveError> {
8194    let Some((finalized, safe)) = finalized.zip(safe) else {
8195        return Ok(());
8196    };
8197    if finalized.number.checked_add(1) == Some(safe.number)
8198        && safe.parent_hash != Some(finalized.hash)
8199    {
8200        return Err(ReactiveError::InvalidChainControl {
8201            message: "adjacent safe head does not descend from finalized head".into(),
8202        });
8203    }
8204    Ok(())
8205}
8206
8207/// Fold every address a [`StateDiff`] references — genuine changes
8208/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike —
8209/// into `into`. Used by the per-block root gate to accumulate the batch's
8210/// decoder-touched address set: an account a decoder wrote (or tried to write) is
8211/// "covered," so a subsequent root move for it is not a coverage gap.
8212fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
8213    into.extend(diff.slots.iter().map(|change| change.address));
8214    into.extend(diff.accounts.iter().map(|change| change.address));
8215    into.extend(diff.purged.iter().map(|purge| purge.address));
8216    into.extend(diff.skipped.iter().map(|skipped| skipped.address));
8217    into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
8218    into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
8219    into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
8220}
8221
8222/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules
8223/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the
8224/// existing account-resync path (Wave 2). The id is derived from the address and
8225/// block so a repeated move on the same account/block coalesces deterministically.
8226fn root_moved_account_resync(
8227    address: Address,
8228    block: u64,
8229    fields: AccountFieldMask,
8230) -> ResyncRequest {
8231    ResyncRequest {
8232        id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
8233        reason: ResyncReason::RootMoved,
8234        block: ResyncBlock::Number(block),
8235        targets: vec![ResyncTarget::Account { address, fields }],
8236        priority: ResyncPriority::Normal,
8237    }
8238}
8239
8240fn batch_preconfirmation<N: Network>(
8241    batch: &ReactiveInputBatch<N>,
8242) -> Result<Option<FlashblockRef>, ReactiveError> {
8243    let mut flashblock: Option<FlashblockRef> = None;
8244    let mut has_non_preconfirmed = false;
8245    for (index, record) in batch.records().iter().enumerate() {
8246        match &record.context.chain_status {
8247            ChainStatus::Preconfirmed {
8248                flashblock: current,
8249            } => {
8250                if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
8251                    return Err(ReactiveError::InvalidInputRecord {
8252                        message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
8253                    });
8254                }
8255                if flashblock
8256                    .as_ref()
8257                    .is_some_and(|known| known != current.as_ref())
8258                {
8259                    return Err(ReactiveError::InvalidInputRecord {
8260                        message: "one batch cannot mix distinct Flashblock snapshots".into(),
8261                    });
8262                }
8263                flashblock.get_or_insert_with(|| current.as_ref().clone());
8264            }
8265            _ => has_non_preconfirmed = true,
8266        }
8267    }
8268    if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
8269        return Err(ReactiveError::InvalidInputRecord {
8270            message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
8271        });
8272    }
8273    Ok(flashblock)
8274}
8275
8276fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
8277    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8278        return None;
8279    }
8280    if is_canonical_status(&record.context.chain_status) {
8281        return context_block_ref(&record.context);
8282    }
8283    None
8284}
8285
8286fn resolve_record_block_payload_metadata<N: Network>(
8287    record: &ReactiveInputRecord<N>,
8288    mut block: BlockRef,
8289) -> Result<BlockRef, ReactiveError> {
8290    let ReactiveInput::Log(log) = &record.input else {
8291        return Ok(block);
8292    };
8293    if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
8294        return Err(ReactiveError::InvalidInputRecord {
8295            message: "log payload and canonical context carry different block identities".into(),
8296        });
8297    }
8298    if let Some(timestamp) = log.block_timestamp {
8299        if block.timestamp.is_some_and(|known| known != timestamp) {
8300            return Err(ReactiveError::InvalidInputRecord {
8301                message: "log payload and canonical context carry different block timestamps"
8302                    .into(),
8303            });
8304        }
8305        block.timestamp = Some(timestamp);
8306    }
8307    Ok(block)
8308}
8309
8310fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
8311    let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
8312    if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
8313        && record.context.block != Some(flashblock.block_ref())
8314    {
8315        return Err(invalid(
8316            "pre-confirmed status and context carry different partial block identities".into(),
8317        ));
8318    }
8319    let status_block = match &record.context.chain_status {
8320        ChainStatus::Included { block, .. }
8321        | ChainStatus::Safe { block }
8322        | ChainStatus::Finalized { block }
8323        | ChainStatus::Reorged {
8324            dropped_from: block,
8325        } => Some(block),
8326        ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
8327        ChainStatus::Pending => None,
8328    };
8329    match (status_block, record.context.block.as_ref()) {
8330        (Some(status), Some(context)) if status == context => {}
8331        (Some(_), Some(_)) => {
8332            return Err(invalid(
8333                "chain status and context carry different block identities".into(),
8334            ));
8335        }
8336        (Some(_), None) => {
8337            return Err(invalid(
8338                "included or reorged input is missing its context block".into(),
8339            ));
8340        }
8341        (None, Some(_)) => {
8342            return Err(invalid(
8343                "pending input cannot carry a canonical context block".into(),
8344            ));
8345        }
8346        (None, None) => {}
8347    }
8348
8349    match &record.input {
8350        ReactiveInput::Log(log) => {
8351            let Some(block) = status_block else {
8352                return Err(invalid(
8353                    "log input must carry an included or reorged block identity".into(),
8354                ));
8355            };
8356            if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
8357                return Err(invalid(
8358                    "removed log must carry reorged chain status".into(),
8359                ));
8360            }
8361            let block_number = log
8362                .block_number
8363                .ok_or_else(|| invalid("log is missing its block number".into()))?;
8364            let block_hash = log
8365                .block_hash
8366                .ok_or_else(|| invalid("log is missing its block hash".into()))?;
8367            log.transaction_hash
8368                .ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
8369            let transaction_index = log
8370                .transaction_index
8371                .ok_or_else(|| invalid("log is missing its transaction index".into()))?;
8372            let log_index = log
8373                .log_index
8374                .ok_or_else(|| invalid("log is missing its log index".into()))?;
8375            if block_number != block.number
8376                || block_hash != block.hash
8377                || !optional_metadata_compatible(
8378                    log.block_timestamp.as_ref(),
8379                    block.timestamp.as_ref(),
8380                )
8381            {
8382                return Err(invalid(
8383                    "log payload and context carry different block identities".into(),
8384                ));
8385            }
8386            if record.context.transaction_index != Some(transaction_index)
8387                || record.context.log_index != Some(log_index)
8388            {
8389                return Err(invalid(
8390                    "log payload and context carry different transaction/log positions".into(),
8391                ));
8392            }
8393        }
8394        ReactiveInput::BlockHeader(header) => {
8395            if let Some(block) = status_block {
8396                if header.number() != block.number
8397                    || header.hash() != block.hash
8398                    || Some(header.parent_hash()) != block.parent_hash
8399                    || Some(header.timestamp()) != block.timestamp
8400                {
8401                    return Err(invalid(
8402                        "block header payload and context carry different block identities".into(),
8403                    ));
8404                }
8405            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8406                return Err(invalid("block header has an unsupported lifecycle".into()));
8407            }
8408            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8409                return Err(invalid(
8410                    "block header context cannot carry transaction/log positions".into(),
8411                ));
8412            }
8413        }
8414        ReactiveInput::FullBlock(block_response) => {
8415            let header = block_response.header();
8416            if let Some(block) = status_block {
8417                if header.number() != block.number
8418                    || header.hash() != block.hash
8419                    || Some(header.parent_hash()) != block.parent_hash
8420                    || Some(header.timestamp()) != block.timestamp
8421                {
8422                    return Err(invalid(
8423                        "full-block payload and context carry different block identities".into(),
8424                    ));
8425                }
8426            } else if !matches!(record.context.chain_status, ChainStatus::Pending) {
8427                return Err(invalid("full block has an unsupported lifecycle".into()));
8428            }
8429            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8430                return Err(invalid(
8431                    "full-block context cannot carry transaction/log positions".into(),
8432                ));
8433            }
8434            if let Some(transactions) = block_response.transactions().as_transactions() {
8435                for (index, transaction) in transactions.iter().enumerate() {
8436                    if transaction
8437                        .block_hash()
8438                        .is_some_and(|hash| hash != header.hash())
8439                        || transaction
8440                            .block_number()
8441                            .is_some_and(|number| number != header.number())
8442                        || transaction
8443                            .transaction_index()
8444                            .is_some_and(|position| position != index as u64)
8445                    {
8446                        return Err(invalid(format!(
8447                            "full-block transaction {index} carries contradictory inclusion metadata"
8448                        )));
8449                    }
8450                    if transaction
8451                        .chain_id()
8452                        .zip(record.context.chain_id)
8453                        .is_some_and(|(transaction, context)| transaction != context)
8454                    {
8455                        return Err(invalid(format!(
8456                            "full-block transaction {index} carries a chain id conflicting with its context"
8457                        )));
8458                    }
8459                }
8460            }
8461        }
8462        ReactiveInput::PendingTxHash(_) => {
8463            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8464                return Err(invalid(
8465                    "pending transaction input must carry pending chain status".into(),
8466                ));
8467            }
8468            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8469                return Err(invalid(
8470                    "pending transaction context cannot carry canonical positions".into(),
8471                ));
8472            }
8473        }
8474        ReactiveInput::PendingTx(transaction) => {
8475            if !matches!(record.context.chain_status, ChainStatus::Pending) {
8476                return Err(invalid(
8477                    "pending transaction input must carry pending chain status".into(),
8478                ));
8479            }
8480            if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
8481                return Err(invalid(
8482                    "pending transaction context cannot carry canonical positions".into(),
8483                ));
8484            }
8485            if transaction.block_hash().is_some()
8486                || transaction.block_number().is_some()
8487                || transaction.transaction_index().is_some()
8488            {
8489                return Err(invalid(
8490                    "hydrated pending transaction cannot carry inclusion metadata".into(),
8491                ));
8492            }
8493            if transaction
8494                .chain_id()
8495                .zip(record.context.chain_id)
8496                .is_some_and(|(transaction, context)| transaction != context)
8497            {
8498                return Err(invalid(
8499                    "pending transaction carries a chain id conflicting with its context".into(),
8500                ));
8501            }
8502        }
8503    }
8504    Ok(())
8505}
8506
8507/// Best-effort per-block env refresh (Phase-8 step 2).
8508///
8509/// For a canonical record carrying a full header — a
8510/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the
8511/// cache's block env from that header via [`EvmCache::advance_block`]. Returns
8512/// `Some(result)` when a header was present (so the caller can surface a strict
8513/// validation error), and `None` for pending/reorged records or non-header
8514/// inputs, which must never drive a canonical env refresh.
8515fn advance_block_for_canonical_record<N: Network>(
8516    cache: &mut EvmCache,
8517    record: &ReactiveInputRecord<N>,
8518) -> Option<Result<(), BlockContextError>> {
8519    if !is_canonical_status(&record.context.chain_status) {
8520        return None;
8521    }
8522    match &record.input {
8523        ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
8524        ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
8525        _ => None,
8526    }
8527}
8528
8529fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
8530    match &ctx.chain_status {
8531        ChainStatus::Included { block, .. }
8532        | ChainStatus::Safe { block }
8533        | ChainStatus::Finalized { block } => Some(block),
8534        ChainStatus::Reorged { dropped_from } => Some(dropped_from),
8535        ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(),
8536        ChainStatus::Pending => ctx.block.as_ref(),
8537    }
8538}
8539
8540fn reorg_signal_block<N: Network>(
8541    record: &ReactiveInputRecord<N>,
8542) -> Option<(BlockRef, ReorgReason)> {
8543    if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
8544        return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
8545    }
8546
8547    if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
8548        return Some((*dropped_from, ReorgReason::ReorgedInput));
8549    }
8550
8551    None
8552}
8553
8554fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
8555    context_block_ref(&record.context)
8556        .cloned()
8557        .or_else(|| match &record.input {
8558            ReactiveInput::Log(log) => Some(BlockRef {
8559                number: log.block_number?,
8560                hash: log.block_hash?,
8561                parent_hash: None,
8562                timestamp: log.block_timestamp,
8563            }),
8564            ReactiveInput::BlockHeader(header) => Some(BlockRef {
8565                number: header.number(),
8566                hash: header.hash(),
8567                parent_hash: Some(header.parent_hash()),
8568                timestamp: Some(header.timestamp()),
8569            }),
8570            ReactiveInput::FullBlock(block) => {
8571                let header = block.header();
8572                Some(BlockRef {
8573                    number: header.number(),
8574                    hash: header.hash(),
8575                    parent_hash: Some(header.parent_hash()),
8576                    timestamp: Some(header.timestamp()),
8577                })
8578            }
8579            ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
8580        })
8581}
8582
8583fn remove_canceled_resyncs_from_batch(
8584    resyncs: &mut Vec<ResyncRequest>,
8585    canceled: &[ResyncRequest],
8586) {
8587    if canceled.is_empty() {
8588        return;
8589    }
8590    let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
8591    resyncs.retain(|request| !canceled_ids.contains(&request.id));
8592}
8593
8594fn resync_target_address(target: &ResyncTarget) -> Address {
8595    match target {
8596        ResyncTarget::StorageSlot { address, .. }
8597        | ResyncTarget::StorageSlots { address, .. }
8598        | ResyncTarget::Account { address, .. } => *address,
8599    }
8600}
8601
8602fn resync_request_targets_dropped_block(
8603    request: &ResyncRequest,
8604    dropped_blocks: &[BlockRef],
8605) -> bool {
8606    let ResyncBlock::Hash { number, hash, .. } = &request.block else {
8607        return false;
8608    };
8609    dropped_blocks
8610        .iter()
8611        .any(|block| block.hash == *hash && block.number == *number)
8612}
8613
8614fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
8615    let first = report.requested.first()?.block.clone();
8616    if !report
8617        .requested
8618        .iter()
8619        .all(|request| request.block == first)
8620    {
8621        return None;
8622    }
8623
8624    let ResyncBlock::Hash { number, hash, .. } = first else {
8625        return None;
8626    };
8627
8628    Some(BlockRef {
8629        number,
8630        hash,
8631        parent_hash: None,
8632        timestamp: None,
8633    })
8634}
8635
8636fn purge_scopes_for_dropped_journals<N: Network>(
8637    dropped: &[BlockJournal<N>],
8638) -> Vec<(Address, PurgeScope)> {
8639    let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
8640    for entry in dropped.iter().rev() {
8641        for diff in entry.rollback_diffs.iter().rev() {
8642            merge_purge_scopes_for_diff(&mut scopes, diff);
8643        }
8644    }
8645    scopes
8646}
8647
8648fn rollback_updates_for_dropped_journals<N: Network>(
8649    dropped: &[BlockJournal<N>],
8650    purge_scopes: &[(Address, PurgeScope)],
8651) -> Vec<StateUpdate> {
8652    let purge_addresses: HashSet<_> = purge_scopes
8653        .iter()
8654        .map(|(address, _scope)| *address)
8655        .collect();
8656    let mut updates = Vec::new();
8657    for entry in dropped.iter().rev() {
8658        for diff in entry.rollback_diffs.iter().rev() {
8659            push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses);
8660        }
8661    }
8662    updates
8663}
8664
8665fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
8666    for change in &diff.accounts {
8667        merge_purge_scope(scopes, change.address, PurgeScope::Account);
8668    }
8669    for record in &diff.purged {
8670        merge_purge_scope(scopes, record.address, record.scope.clone());
8671    }
8672}
8673
8674fn push_rollback_updates_for_diff(
8675    updates: &mut Vec<StateUpdate>,
8676    diff: &StateDiff,
8677    purge_addresses: &HashSet<Address>,
8678) {
8679    for change in diff.slots.iter().rev() {
8680        if purge_addresses.contains(&change.address) {
8681            continue;
8682        }
8683        updates.push(StateUpdate::slot(change.address, change.slot, change.old));
8684    }
8685}
8686
8687fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
8688    if let Some((_existing_address, existing_scope)) = scopes
8689        .iter_mut()
8690        .find(|(existing_address, _scope)| *existing_address == address)
8691    {
8692        *existing_scope = merged_purge_scope(existing_scope.clone(), scope);
8693    } else {
8694        scopes.push((address, scope));
8695    }
8696}
8697
8698fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
8699    match (left, right) {
8700        (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
8701        (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
8702        (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
8703            for slot in right {
8704                if !left.contains(&slot) {
8705                    left.push(slot);
8706                }
8707            }
8708            PurgeScope::Slots(left)
8709        }
8710    }
8711}
8712
8713#[derive(Clone, Debug)]
8714struct StorageFetchSlot {
8715    address: Address,
8716    slot: U256,
8717    origins: Vec<StorageFetchOrigin>,
8718}
8719
8720#[derive(Clone, Debug)]
8721struct StorageFetchOrigin {
8722    request_id: ResyncId,
8723    target: ResyncTarget,
8724}
8725
8726#[derive(Clone, Debug)]
8727struct StorageFetchGroup {
8728    block: ResyncBlock,
8729    slots: Vec<StorageFetchSlot>,
8730    seen: HashSet<(Address, U256)>,
8731}
8732
8733/// One account-target resync collected during request scanning, resolved through
8734/// the account proof fetcher after storage groups are processed.
8735#[derive(Clone, Debug)]
8736struct AccountResyncTarget {
8737    request_id: ResyncId,
8738    block: ResyncBlock,
8739    address: Address,
8740    fields: AccountFieldMask,
8741}
8742
8743fn resolve_trace_resyncs(
8744    cache: &EvmCache,
8745    storage_groups: &mut Vec<StorageFetchGroup>,
8746    account_targets: &mut Vec<AccountResyncTarget>,
8747    state_updates: &mut Vec<StateUpdate>,
8748) {
8749    let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
8750        return;
8751    };
8752
8753    let mut blocks = Vec::new();
8754    let mut seen = HashSet::new();
8755    for block in storage_groups
8756        .iter()
8757        .map(|group| group.block.clone())
8758        .chain(account_targets.iter().map(|target| target.block.clone()))
8759    {
8760        if seen.insert(block.clone()) {
8761            blocks.push(block);
8762        }
8763    }
8764
8765    let mut traces = HashMap::new();
8766    for block in blocks {
8767        match (fetcher)(resync_block_to_block_id(&block)) {
8768            Ok(diff) => {
8769                traces.insert(block, diff);
8770            }
8771            Err(error) => {
8772                tracing::debug!(
8773                    block = ?block,
8774                    error = %error,
8775                    "block trace resync source failed; falling back to point resync"
8776                );
8777            }
8778        }
8779    }
8780
8781    for group in storage_groups.iter_mut() {
8782        let Some(trace) = traces.get(&group.block) else {
8783            continue;
8784        };
8785        group.slots.retain(|slot| {
8786            if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
8787                state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
8788                return false;
8789            }
8790            cache
8791                .cached_storage_value(slot.address, slot.slot)
8792                .is_none()
8793        });
8794        group.seen = group
8795            .slots
8796            .iter()
8797            .map(|slot| (slot.address, slot.slot))
8798            .collect();
8799    }
8800    storage_groups.retain(|group| !group.slots.is_empty());
8801
8802    let mut unresolved_accounts = Vec::new();
8803    for mut account in account_targets.drain(..) {
8804        let Some(trace) = traces.get(&account.block) else {
8805            unresolved_accounts.push(account);
8806            continue;
8807        };
8808        let Some(trace_account) = trace
8809            .accounts
8810            .iter()
8811            .find(|diff| diff.address == account.address)
8812        else {
8813            unresolved_accounts.push(account);
8814            continue;
8815        };
8816
8817        let mut patch = AccountPatch::default();
8818        let mut unresolved = AccountFieldMask::default();
8819        if account.fields.balance {
8820            if let Some(balance) = trace_account.balance {
8821                patch = patch.balance(balance);
8822            } else {
8823                unresolved.balance = true;
8824            }
8825        }
8826        if account.fields.nonce {
8827            if let Some(nonce) = trace_account.nonce {
8828                patch = patch.nonce(nonce);
8829            } else {
8830                unresolved.nonce = true;
8831            }
8832        }
8833        if account.fields.code {
8834            if let Some(code) = &trace_account.code {
8835                patch = patch.code(code.clone());
8836            } else {
8837                unresolved.code = true;
8838            }
8839        }
8840
8841        if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
8842            state_updates.push(StateUpdate::account_upsert(account.address, patch));
8843        }
8844        if !account_field_mask_empty(unresolved) {
8845            account.fields = unresolved;
8846            unresolved_accounts.push(account);
8847        }
8848    }
8849    *account_targets = unresolved_accounts;
8850}
8851
8852fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
8853    trace
8854        .accounts
8855        .iter()
8856        .find(|account| account.address == address)
8857        .and_then(|account| {
8858            account
8859                .storage
8860                .iter()
8861                .find(|entry| entry.slot == slot)
8862                .map(|entry| entry.value)
8863        })
8864}
8865
8866fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
8867    !mask.balance && !mask.nonce && !mask.code
8868}
8869
8870fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
8871    let mut failed = Vec::new();
8872    let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
8873    let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
8874
8875    for request in requests {
8876        for target in &request.targets {
8877            match target {
8878                ResyncTarget::StorageSlot { address, slot } => {
8879                    push_storage_resync_slot(
8880                        &mut storage_groups,
8881                        &request.id,
8882                        &request.block,
8883                        *address,
8884                        *slot,
8885                    );
8886                }
8887                ResyncTarget::StorageSlots { address, slots } => {
8888                    for slot in slots {
8889                        push_storage_resync_slot(
8890                            &mut storage_groups,
8891                            &request.id,
8892                            &request.block,
8893                            *address,
8894                            *slot,
8895                        );
8896                    }
8897                }
8898                ResyncTarget::Account { address, fields } => {
8899                    account_targets.push(AccountResyncTarget {
8900                        request_id: request.id.clone(),
8901                        block: request.block.clone(),
8902                        address: *address,
8903                        fields: *fields,
8904                    });
8905                }
8906            }
8907        }
8908    }
8909
8910    let mut state_updates = Vec::new();
8911    resolve_trace_resyncs(
8912        cache,
8913        &mut storage_groups,
8914        &mut account_targets,
8915        &mut state_updates,
8916    );
8917
8918    if !storage_groups.is_empty() {
8919        if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
8920            for group in storage_groups {
8921                let block = group.block.clone();
8922                let fetches: Vec<(Address, U256)> = group
8923                    .slots
8924                    .iter()
8925                    .map(|slot| (slot.address, slot.slot))
8926                    .collect();
8927                let results = (fetcher)(fetches, resync_block_to_block_id(&block));
8928                let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
8929                    .slots
8930                    .iter()
8931                    .cloned()
8932                    .map(|slot| ((slot.address, slot.slot), slot))
8933                    .collect();
8934
8935                for (address, slot, fetched) in results {
8936                    let Some(requested_slot) = pending.remove(&(address, slot)) else {
8937                        continue;
8938                    };
8939                    match fetched {
8940                        Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
8941                        Err(error) => {
8942                            let message = error.to_string();
8943                            push_resync_failures(
8944                                &mut failed,
8945                                &block,
8946                                requested_slot.origins,
8947                                ResyncFailureKind::StorageFetchFailed,
8948                                message,
8949                            );
8950                        }
8951                    }
8952                }
8953
8954                for requested_slot in group.slots {
8955                    if pending
8956                        .remove(&(requested_slot.address, requested_slot.slot))
8957                        .is_some()
8958                    {
8959                        push_resync_failures(
8960                            &mut failed,
8961                            &block,
8962                            requested_slot.origins,
8963                            ResyncFailureKind::StorageFetchOmitted,
8964                            "storage batch fetcher did not return a value for slot".to_string(),
8965                        );
8966                    }
8967                }
8968            }
8969        } else {
8970            for group in storage_groups {
8971                let block = group.block.clone();
8972                for slot in group.slots {
8973                    push_resync_failures(
8974                        &mut failed,
8975                        &block,
8976                        slot.origins,
8977                        ResyncFailureKind::MissingStorageFetcher,
8978                        "storage resync requires a storage batch fetcher".to_string(),
8979                    );
8980                }
8981            }
8982        }
8983    }
8984
8985    if !account_targets.is_empty() {
8986        if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
8987            // ONE seam invocation per distinct resync block (targets may pin
8988            // different blocks): eth_getProof is single-address at the RPC
8989            // level, so batching the addresses lets the fetcher fan the
8990            // requests out concurrently instead of paying one round trip per
8991            // account. Root-only probes: account fields need no storage keys.
8992            let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
8993            for account in account_targets {
8994                let block_id = resync_block_to_block_id(&account.block);
8995                match groups
8996                    .iter_mut()
8997                    .find(|(group_block, _)| *group_block == block_id)
8998                {
8999                    Some((_, group)) => group.push(account),
9000                    None => groups.push((block_id, vec![account])),
9001                }
9002            }
9003            for (block_id, group) in groups {
9004                let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
9005                    group
9006                        .iter()
9007                        .map(|account| (account.address, vec![]))
9008                        .collect(),
9009                    block_id,
9010                )
9011                .into_iter()
9012                .collect();
9013                for account in group {
9014                    // `get` + clone rather than `remove`: two targets for the
9015                    // same address in one group must both resolve from the
9016                    // single probe.
9017                    match probes.get(&account.address).cloned() {
9018                        Some(Ok(proof)) => {
9019                            // Build an authoritative account update from the requested
9020                            // field mask. Use the MATERIALIZING `account_upsert` so a
9021                            // resync applies even to a cold account (a partial `Account`
9022                            // patch on a cold address is silently skipped).
9023                            let mut patch = AccountPatch::default();
9024                            if account.fields.balance {
9025                                patch = patch.balance(proof.balance);
9026                            }
9027                            if account.fields.nonce {
9028                                patch = patch.nonce(proof.nonce);
9029                            }
9030                            // Note: `AccountProof` carries `code_hash`, not code bytes;
9031                            // the `eth_getProof` seam cannot supply runtime code, so a
9032                            // code-field resync is a no-op here (code freshness is
9033                            // handled by a later wave). We still materialize the account
9034                            // so requested balance/nonce fields take effect.
9035                            state_updates.push(StateUpdate::account_upsert(account.address, patch));
9036                        }
9037                        Some(Err(error)) => {
9038                            failed.push(ResyncFailure {
9039                                request_id: account.request_id,
9040                                block: account.block,
9041                                target: ResyncTarget::Account {
9042                                    address: account.address,
9043                                    fields: account.fields,
9044                                },
9045                                kind: ResyncFailureKind::AccountFetchFailed,
9046                                message: error.to_string(),
9047                            });
9048                        }
9049                        None => {
9050                            failed.push(ResyncFailure {
9051                                request_id: account.request_id,
9052                                block: account.block,
9053                                target: ResyncTarget::Account {
9054                                    address: account.address,
9055                                    fields: account.fields,
9056                                },
9057                                kind: ResyncFailureKind::AccountFetchOmitted,
9058                                message:
9059                                    "account proof fetcher did not return a result for address"
9060                                        .to_string(),
9061                            });
9062                        }
9063                    }
9064                }
9065            }
9066        } else {
9067            for account in account_targets {
9068                failed.push(ResyncFailure {
9069                    request_id: account.request_id,
9070                    block: account.block,
9071                    target: ResyncTarget::Account {
9072                        address: account.address,
9073                        fields: account.fields,
9074                    },
9075                    kind: ResyncFailureKind::MissingAccountFetcher,
9076                    message: "account resync requires an account proof fetcher".to_string(),
9077                });
9078            }
9079        }
9080    }
9081
9082    let diff = if state_updates.is_empty() {
9083        StateDiff::default()
9084    } else {
9085        cache.apply_updates(&state_updates)
9086    };
9087
9088    ResyncReport {
9089        requested: requests.to_vec(),
9090        state_updates,
9091        diff,
9092        failed,
9093    }
9094}
9095
9096fn push_resync_failures(
9097    failed: &mut Vec<ResyncFailure>,
9098    block: &ResyncBlock,
9099    origins: Vec<StorageFetchOrigin>,
9100    kind: ResyncFailureKind,
9101    message: String,
9102) {
9103    for origin in origins {
9104        failed.push(ResyncFailure {
9105            request_id: origin.request_id,
9106            block: block.clone(),
9107            target: origin.target,
9108            kind,
9109            message: message.clone(),
9110        });
9111    }
9112}
9113
9114fn push_storage_resync_slot(
9115    groups: &mut Vec<StorageFetchGroup>,
9116    request_id: &ResyncId,
9117    block: &ResyncBlock,
9118    address: Address,
9119    slot: U256,
9120) {
9121    let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
9122        index
9123    } else {
9124        groups.push(StorageFetchGroup {
9125            block: block.clone(),
9126            slots: Vec::new(),
9127            seen: HashSet::new(),
9128        });
9129        groups.len() - 1
9130    };
9131
9132    let group = &mut groups[group_index];
9133    let origin = StorageFetchOrigin {
9134        request_id: request_id.clone(),
9135        target: ResyncTarget::StorageSlot { address, slot },
9136    };
9137    if group.seen.insert((address, slot)) {
9138        group.slots.push(StorageFetchSlot {
9139            address,
9140            slot,
9141            origins: vec![origin],
9142        });
9143    } else if let Some(existing) = group
9144        .slots
9145        .iter_mut()
9146        .find(|existing| existing.address == address && existing.slot == slot)
9147    {
9148        existing.origins.push(origin);
9149    }
9150}
9151
9152fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
9153    match block {
9154        ResyncBlock::Latest => BlockId::latest(),
9155        ResyncBlock::Pending => BlockId::pending(),
9156        ResyncBlock::Safe => BlockId::safe(),
9157        ResyncBlock::Finalized => BlockId::finalized(),
9158        ResyncBlock::Number(number) => BlockId::number(*number),
9159        ResyncBlock::Hash {
9160            number: _,
9161            hash,
9162            require_canonical,
9163        } => BlockId::from((*hash, Some(*require_canonical))),
9164    }
9165}
9166
9167impl<N: Network> RegisteredHandler<N> {
9168    fn matches(&self, input: &ReactiveInput<N>) -> bool {
9169        self.interests
9170            .iter()
9171            .any(|interest| interest_matches(interest, input))
9172    }
9173
9174    fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
9175        self.interests.iter().find_map(|interest| match interest {
9176            ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
9177                handler_id: self.id.clone(),
9178                route_key: interest.route_key(log),
9179            }),
9180            ReactiveInterest::Logs(_)
9181            | ReactiveInterest::Blocks(_)
9182            | ReactiveInterest::PendingTransactions(_) => None,
9183        })
9184    }
9185}
9186
9187fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
9188    let mut candidate = next.clone();
9189    let mut insertion_index = filters.len();
9190    let mut index = 0;
9191    while index < filters.len() {
9192        if filters[index].block_option != candidate.block_option {
9193            index += 1;
9194            continue;
9195        }
9196        if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
9197            candidate = merged;
9198            insertion_index = insertion_index.min(index);
9199            filters.remove(index);
9200            index = 0;
9201        } else {
9202            index += 1;
9203        }
9204    }
9205    filters.insert(insertion_index.min(filters.len()), candidate);
9206}
9207
9208fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
9209    if filter_subsumes(left, right) {
9210        return Some(left.clone());
9211    }
9212    if filter_subsumes(right, left) {
9213        return Some(right.clone());
9214    }
9215    let differing_dimensions = usize::from(left.address != right.address)
9216        + left
9217            .topics
9218            .iter()
9219            .zip(right.topics.iter())
9220            .filter(|(left, right)| left != right)
9221            .count();
9222    if differing_dimensions != 1 {
9223        return None;
9224    }
9225
9226    let mut merged = left.clone();
9227    if merged.address != right.address {
9228        merge_filter_set(&mut merged.address, &right.address);
9229    } else {
9230        for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
9231            if merged_topic != right_topic {
9232                merge_filter_set(merged_topic, right_topic);
9233                break;
9234            }
9235        }
9236    }
9237    Some(merged)
9238}
9239
9240fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
9241    filter_set_subsumes(&left.address, &right.address)
9242        && left
9243            .topics
9244            .iter()
9245            .zip(right.topics.iter())
9246            .all(|(left, right)| filter_set_subsumes(left, right))
9247}
9248
9249fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
9250    left.is_empty()
9251        || (!right.is_empty()
9252            && right
9253                .iter()
9254                .all(|value| left.iter().any(|known| known == value)))
9255}
9256
9257fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
9258    if target.is_empty() {
9259        return;
9260    }
9261    if source.is_empty() {
9262        *target = FilterSet::default();
9263        return;
9264    }
9265    for value in source.iter() {
9266        target.insert(value.clone());
9267    }
9268}
9269
9270#[derive(Clone, Debug)]
9271struct HandlerExecution {
9272    handler_id: HandlerId,
9273    quality: StateEffectQuality,
9274    tags: Vec<ReportTag>,
9275    state_updates: Vec<StateUpdate>,
9276    invalidations: Vec<InvalidationRequest>,
9277    resyncs: Vec<ResyncRequest>,
9278    speculative: Vec<SpeculativeRequest>,
9279    hook_signals: Vec<HookSignal>,
9280}
9281
9282impl HandlerExecution {
9283    fn from_outcome(
9284        handler_id: HandlerId,
9285        input_ref: InputRef,
9286        outcome: HandlerOutcome,
9287        preconfirmed: bool,
9288    ) -> Self {
9289        let mut state_updates = Vec::new();
9290        let mut invalidations = Vec::new();
9291        let mut resyncs = Vec::new();
9292        let mut speculative = Vec::new();
9293        let mut hook_signals = Vec::new();
9294
9295        for effect in outcome.effects {
9296            match effect {
9297                ReactiveEffect::StateUpdate(update) => state_updates.push(update),
9298                ReactiveEffect::Invalidate(invalidation) => {
9299                    state_updates.push(StateUpdate::purge(
9300                        invalidation.address,
9301                        invalidation.scope.clone(),
9302                    ));
9303                    invalidations.push(invalidation);
9304                }
9305                ReactiveEffect::Resync(mut request) => {
9306                    if preconfirmed {
9307                        request.block = ResyncBlock::Pending;
9308                    }
9309                    resyncs.push(request);
9310                }
9311                ReactiveEffect::Hook(signal) => hook_signals.push(signal),
9312                ReactiveEffect::Speculative(mut request) => {
9313                    request.input_ref = input_ref;
9314                    speculative.push(request);
9315                }
9316            }
9317        }
9318
9319        Self {
9320            handler_id,
9321            quality: outcome.quality,
9322            tags: outcome.tags,
9323            state_updates,
9324            invalidations,
9325            resyncs,
9326            speculative,
9327            hook_signals,
9328        }
9329    }
9330}
9331
9332fn dedupe_records<N: Network>(
9333    records: Vec<ReactiveInputRecord<N>>,
9334) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
9335    let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
9336    let mut deduped = Vec::with_capacity(records.len());
9337    for record in records {
9338        let identity = record.validated_identity()?;
9339        if !record.is_payload_deduplicable() {
9340            deduped.push(record);
9341            continue;
9342        }
9343        if let Some(index) = positions.get(&identity).copied() {
9344            let merged = deduped[index].merge_compatible_duplicate(&record)?;
9345            debug_assert!(merged, "same indexed identity is deduplicable");
9346        } else {
9347            positions.insert(identity, deduped.len());
9348            deduped.push(record);
9349        }
9350    }
9351    Ok(deduped)
9352}
9353
9354fn dedupe_scoped_records<N: Network>(
9355    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9356) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
9357    let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
9358    let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
9359        Vec::with_capacity(records.len());
9360    for (record, audience, delivery_scope) in records {
9361        let identity = record.validated_identity()?;
9362        if !record.is_payload_deduplicable() {
9363            deduped.push((record, audience, delivery_scope));
9364            continue;
9365        }
9366        if let Some(index) = positions.get(&identity).copied() {
9367            let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
9368            debug_assert!(merged, "same indexed identity is deduplicable");
9369            merge_delivery_audience(&mut deduped[index].1, audience);
9370            merge_delivery_scope(&mut deduped[index].2, delivery_scope);
9371        } else {
9372            positions.insert(identity, deduped.len());
9373            deduped.push((record, audience, delivery_scope));
9374        }
9375    }
9376    Ok(deduped)
9377}
9378
9379fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
9380    *into = match (*into, incoming) {
9381        (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
9382        (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
9383            DeliveryScope::CanonicalProgress
9384        }
9385        (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
9386        | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
9387        | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
9388        (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
9389    };
9390}
9391
9392fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
9393    match (&mut *into, incoming) {
9394        (DeliveryAudience::All, _) => {}
9395        (current, DeliveryAudience::All) => *current = DeliveryAudience::All,
9396        (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
9397            for owner in incoming {
9398                if !current.contains(&owner) {
9399                    current.push(owner);
9400                }
9401            }
9402        }
9403        (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
9404            current.retain(|owner| incoming.contains(owner));
9405        }
9406        (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
9407            excluded.retain(|owner| !included.contains(owner));
9408        }
9409        (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
9410            let DeliveryAudience::Owners(included) = current else {
9411                unreachable!("match arm restricts the audience variant")
9412            };
9413            excluded.retain(|owner| !included.contains(owner));
9414            *current = DeliveryAudience::AllExcept(excluded);
9415        }
9416    }
9417}
9418
9419fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
9420    let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
9421        records.into_iter().enumerate().collect();
9422    indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
9423    indexed.into_iter().map(|(_, record)| record).collect()
9424}
9425
9426fn sort_scoped_records<N: Network>(
9427    records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
9428) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
9429    let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
9430    indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
9431    indexed
9432        .into_iter()
9433        .map(|(_, scoped_record)| scoped_record)
9434        .collect()
9435}
9436
9437fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
9438    if let Some((block, _)) = reorg_signal_block(record) {
9439        return RecordSortKey {
9440            class: 0,
9441            block_number: block.number,
9442            record_class: 0,
9443            transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
9444            log_index: record.context.log_index.unwrap_or(u64::MAX),
9445            original_index: index,
9446        };
9447    }
9448    if is_canonical_status(&record.context.chain_status)
9449        && let Some(block) = record.context.block.as_ref()
9450    {
9451        let (record_class, transaction_index, log_index) = match &record.input {
9452            ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
9453            ReactiveInput::Log(log) if !log.removed => (
9454                1,
9455                log.transaction_index
9456                    .or(record.context.transaction_index)
9457                    .unwrap_or(u64::MAX),
9458                log.log_index
9459                    .or(record.context.log_index)
9460                    .unwrap_or(u64::MAX),
9461            ),
9462            ReactiveInput::Log(_)
9463            | ReactiveInput::PendingTxHash(_)
9464            | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
9465        };
9466        return RecordSortKey {
9467            class: 1,
9468            block_number: block.number,
9469            record_class,
9470            transaction_index,
9471            log_index,
9472            original_index: index,
9473        };
9474    }
9475
9476    RecordSortKey {
9477        class: 2,
9478        block_number: 0,
9479        record_class: 0,
9480        transaction_index: 0,
9481        log_index: 0,
9482        original_index: index,
9483    }
9484}
9485
9486#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9487struct RecordSortKey {
9488    class: u8,
9489    block_number: u64,
9490    record_class: u8,
9491    transaction_index: u64,
9492    log_index: u64,
9493    original_index: usize,
9494}
9495
9496fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
9497    match (interest, input) {
9498        (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
9499        (
9500            ReactiveInterest::Blocks(BlockInterest {
9501                mode: BlockInterestMode::Header,
9502            }),
9503            ReactiveInput::BlockHeader(_),
9504        ) => true,
9505        (
9506            ReactiveInterest::Blocks(BlockInterest {
9507                mode: BlockInterestMode::FullBlock,
9508            }),
9509            ReactiveInput::FullBlock(_),
9510        ) => true,
9511        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
9512            interest.matches_hash_only()
9513        }
9514        (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
9515            interest.matches_tx(tx)
9516        }
9517        _ => false,
9518    }
9519}
9520
9521fn validate_effects(
9522    input_ref: InputRef,
9523    ctx: &ReactiveContext,
9524    handler_id: &HandlerId,
9525    effects: &[ReactiveEffect],
9526) -> Result<(), ReactiveError> {
9527    let pending = matches!(ctx.chain_status, ChainStatus::Pending)
9528        || matches!(input_ref, InputRef::PendingTx { .. });
9529    if !pending {
9530        return Ok(());
9531    }
9532
9533    for effect in effects {
9534        let effect_kind = match effect {
9535            ReactiveEffect::StateUpdate(_) => Some("state_update"),
9536            ReactiveEffect::Invalidate(_) => Some("invalidate"),
9537            ReactiveEffect::Resync(_) => Some("resync"),
9538            ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
9539        };
9540        if let Some(effect_kind) = effect_kind {
9541            return Err(ReactiveError::InvalidPendingEffect {
9542                input_ref: Box::new(input_ref),
9543                handler_id: handler_id.clone(),
9544                effect_kind,
9545            });
9546        }
9547    }
9548    Ok(())
9549}
9550
9551fn detect_conflicts(
9552    input_ref: InputRef,
9553    executions: &[HandlerExecution],
9554) -> Result<(), ReactiveError> {
9555    let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
9556    for execution in executions {
9557        for update in &execution.state_updates {
9558            for (target, value) in absolute_writes(update) {
9559                if let Some((previous_value, previous_handler)) = writes.get(&target) {
9560                    if previous_value != &value {
9561                        return Err(ReactiveError::ConflictingEffects {
9562                            input_ref: Box::new(input_ref),
9563                            target: Box::new(target),
9564                            first: previous_handler.clone(),
9565                            second: execution.handler_id.clone(),
9566                        });
9567                    }
9568                } else {
9569                    writes.insert(target, (value, execution.handler_id.clone()));
9570                }
9571            }
9572        }
9573    }
9574    Ok(())
9575}
9576
9577fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
9578    match update {
9579        StateUpdate::Slot {
9580            address,
9581            slot,
9582            value,
9583        } => vec![(
9584            EffectTarget::StorageSlot {
9585                address: *address,
9586                slot: *slot,
9587            },
9588            AbsoluteValue::U256(*value),
9589        )],
9590        StateUpdate::SlotMasked {
9591            address,
9592            slot,
9593            mask,
9594            value,
9595        } => vec![(
9596            EffectTarget::MaskedStorageSlot {
9597                address: *address,
9598                slot: *slot,
9599                mask: *mask,
9600            },
9601            AbsoluteValue::U256(*value),
9602        )],
9603        StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
9604            account_patch_writes(*address, patch)
9605        }
9606        StateUpdate::SlotDelta { .. }
9607        | StateUpdate::BalanceDelta { .. }
9608        | StateUpdate::Purge { .. } => Vec::new(),
9609    }
9610}
9611
9612fn account_patch_writes(
9613    address: Address,
9614    patch: &AccountPatch,
9615) -> Vec<(EffectTarget, AbsoluteValue)> {
9616    let mut writes = Vec::new();
9617    if let Some(balance) = patch.balance {
9618        writes.push((
9619            EffectTarget::AccountBalance { address },
9620            AbsoluteValue::U256(balance),
9621        ));
9622    }
9623    if let Some(nonce) = patch.nonce {
9624        writes.push((
9625            EffectTarget::AccountNonce { address },
9626            AbsoluteValue::U64(nonce),
9627        ));
9628    }
9629    if let Some(code) = &patch.code {
9630        writes.push((
9631            EffectTarget::AccountCode { address },
9632            AbsoluteValue::Bytes(code.clone()),
9633        ));
9634    }
9635    writes
9636}
9637
9638fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
9639    match input {
9640        ReactiveInput::Log(log) => InputRef::Log {
9641            chain_id: ctx.chain_id,
9642            block_hash: log
9643                .block_hash
9644                .or(ctx.block.as_ref().map(|block| block.hash))
9645                .unwrap_or_default(),
9646            transaction_hash: log.transaction_hash.unwrap_or_default(),
9647            log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
9648        },
9649        ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
9650            chain_id: ctx.chain_id,
9651            hash: *hash,
9652        },
9653        ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
9654            chain_id: ctx.chain_id,
9655            hash: tx.tx_hash(),
9656        },
9657        ReactiveInput::BlockHeader(header) => InputRef::Block {
9658            chain_id: ctx.chain_id,
9659            hash: header.hash(),
9660            number: header.number(),
9661        },
9662        ReactiveInput::FullBlock(block) => {
9663            let header = block.header();
9664            InputRef::Block {
9665                chain_id: ctx.chain_id,
9666                hash: header.hash(),
9667                number: header.number(),
9668            }
9669        }
9670    }
9671}
9672
9673fn is_canonical_status(status: &ChainStatus) -> bool {
9674    matches!(
9675        status,
9676        ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
9677    )
9678}
9679
9680/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler.
9681pub struct EventDecoderHandler {
9682    id: HandlerId,
9683    decoder: Arc<dyn EventDecoder>,
9684    interest: LogInterest,
9685}
9686
9687impl EventDecoderHandler {
9688    /// Create an adapter from a decoder and log interest.
9689    pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
9690        Self {
9691            id,
9692            decoder,
9693            interest,
9694        }
9695    }
9696}
9697
9698impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
9699    fn id(&self) -> HandlerId {
9700        self.id.clone()
9701    }
9702
9703    fn interests(&self) -> Vec<ReactiveInterest<N>> {
9704        vec![ReactiveInterest::Logs(self.interest.clone())]
9705    }
9706
9707    fn handle(
9708        &self,
9709        _ctx: &ReactiveContext,
9710        input: &ReactiveInput<N>,
9711        state: &dyn StateView,
9712    ) -> Result<HandlerOutcome, HandlerError> {
9713        let ReactiveInput::Log(log) = input else {
9714            return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
9715        };
9716
9717        Ok(HandlerOutcome {
9718            effects: self
9719                .decoder
9720                .decode(&log.inner, state)
9721                .into_iter()
9722                .map(ReactiveEffect::StateUpdate)
9723                .collect(),
9724            quality: StateEffectQuality::ExactFromInput,
9725            tags: Vec::new(),
9726        })
9727    }
9728}
9729
9730/// One independently negotiable event-subscriber behavior.
9731#[derive(
9732    Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9733)]
9734#[non_exhaustive]
9735pub enum SubscriberCapability {
9736    /// Emit EVM logs.
9737    Logs,
9738    /// Emit block headers.
9739    BlockHeaders,
9740    /// Emit full blocks with transaction bodies.
9741    FullBlocks,
9742    /// Emit pending transaction hashes.
9743    PendingTransactionHashes,
9744    /// Emit hydrated pending transactions.
9745    PendingTransactions,
9746    /// Fetch historical data from a caller-selected anchor.
9747    HistoricalBackfill,
9748    /// Follow live chain data.
9749    Live,
9750    /// Recover the complete committed consumer position after reconnect or
9751    /// restart, including any unacknowledged delivery.
9752    ///
9753    /// An implementation may satisfy this with native stream replay or with a
9754    /// durable cursor plus deterministic historical reconciliation of an
9755    /// ephemeral live child. The end-to-end subscriber must still prove there
9756    /// is no gap between the restored position and resumed live delivery. If an
9757    /// old delivery token is emitted again, that token must identify the same
9758    /// immutable delivery and pass the engine's witness check.
9759    DurableReplay,
9760    /// Preserve logical handler ownership on delivered batches.
9761    OwnerScopedDelivery,
9762    /// Add and remove interests without replacing the complete session.
9763    DynamicInterests,
9764    /// Emit explicit canonical branch transitions.
9765    ExplicitReorgs,
9766    /// Emit safe and finalized head updates.
9767    FinalityUpdates,
9768    /// Emit ordered synchronization or source-cutover barriers.
9769    Barriers,
9770    /// Emit sequencer pre-confirmations into a disposable state overlay.
9771    Preconfirmations,
9772}
9773
9774/// Capability set advertised by an [`EventSubscriber`].
9775///
9776/// The default is deliberately empty: callers can safely reject a topology
9777/// when an older or minimal implementation has not opted into a required
9778/// behavior.
9779#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9780pub struct SubscriberCapabilities {
9781    supported: BTreeSet<SubscriberCapability>,
9782}
9783
9784impl SubscriberCapabilities {
9785    /// Construct a capability set from supported behaviors.
9786    pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
9787        Self {
9788            supported: capabilities.into_iter().collect(),
9789        }
9790    }
9791
9792    /// Test one independently negotiable behavior.
9793    pub fn supports(&self, capability: SubscriberCapability) -> bool {
9794        self.supported.contains(&capability)
9795    }
9796
9797    /// Iterate supported behaviors in stable order.
9798    pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
9799        self.supported.iter().copied()
9800    }
9801
9802    /// Whether the subscriber follows live chain data.
9803    pub fn supports_live(&self) -> bool {
9804        self.supports(SubscriberCapability::Live)
9805    }
9806
9807    /// Whether the subscriber can durably recover its committed position and
9808    /// any unacknowledged delivery without an event gap.
9809    pub fn supports_durable_replay(&self) -> bool {
9810        self.supports(SubscriberCapability::DurableReplay)
9811    }
9812
9813    /// Whether the subscriber emits explicit branch transitions.
9814    pub fn supports_explicit_reorgs(&self) -> bool {
9815        self.supports(SubscriberCapability::ExplicitReorgs)
9816    }
9817}
9818
9819impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
9820    fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
9821        Self::new(iter)
9822    }
9823}
9824
9825/// Provider-agnostic subscriber interface.
9826pub trait EventSubscriber<N: Network = Ethereum>: Send {
9827    /// Chain identity attached to emitted records, when it has been resolved.
9828    ///
9829    /// Remote and provider-backed subscribers should cache one authoritative
9830    /// identity before exposing input. Returning `None` is reserved for
9831    /// synthetic or genuinely chain-agnostic subscribers; composite sources
9832    /// can use this hook to reject accidentally mixed networks.
9833    fn chain_id(&self) -> Option<u64> {
9834        None
9835    }
9836
9837    /// Behaviors this subscriber can uphold for topology validation.
9838    fn capabilities(&self) -> SubscriberCapabilities {
9839        SubscriberCapabilities::default()
9840    }
9841
9842    /// Replace all interests registered with the subscriber.
9843    ///
9844    /// Implementations may use this as a full setup/reset operation. The
9845    /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and
9846    /// delivery/dedupe bookkeeping when this method is called.
9847    ///
9848    /// The returned operation must complete only after the replacement has
9849    /// committed to the subscriber's desired state. Remote implementations can
9850    /// use this asynchronous boundary to wait for an authoritative service-side
9851    /// acknowledgement before returning `Ok(())`. On error, or when the future
9852    /// is dropped before completion, the previously committed desired state
9853    /// must remain authoritative (or be reconciled before later delivery can
9854    /// expose the uncommitted change) so callers can safely retry.
9855    ///
9856    /// # Errors
9857    ///
9858    /// The returned operation reports [`SubscriberError`] when the replacement
9859    /// cannot be validated or committed by the underlying source.
9860    fn register_interests(
9861        &mut self,
9862        interests: &[ReactiveInterest<N>],
9863    ) -> SubscriberOperation<'_, ()>;
9864
9865    /// Return the next input batch, or `Ok(None)` when the stream is exhausted.
9866    ///
9867    /// The returned future must be cancellation-safe: dropping it while pending
9868    /// must not discard a complete input that a later call could otherwise
9869    /// deliver. Composite subscribers use this property to race historical and
9870    /// live sources without dedicating a task to each transport.
9871    ///
9872    /// # Errors
9873    ///
9874    /// The returned future reports [`SubscriberError`] for transport,
9875    /// continuity, decoding, or source-resource failures.
9876    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
9877
9878    /// Restore the subscriber's committed position before polling resumes.
9879    ///
9880    /// The engine invokes this synchronously from
9881    /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime
9882    /// recovery state and before publishing that state as resumed. Implementations
9883    /// should validate that provider/service cursors cannot regress and seed any
9884    /// source epoch or overlap history required for safe replay. A composite may
9885    /// rebuild an ephemeral live child from `coverage_head` plus historical
9886    /// reconciliation rather than require that child to replay bytes itself, but
9887    /// it may advertise [`SubscriberCapability::DurableReplay`] only when the
9888    /// complete restore closes that cutover gap before exposing live input. On
9889    /// error, either
9890    /// the prior position must remain authoritative, or the subscriber may retain
9891    /// this *exact* restore as pending intent; in the latter case it must block
9892    /// delivery and reject conflicting restores until retry/reconciliation commits
9893    /// the same position. This permits synchronous adapters over durable remote
9894    /// state without exposing a half-restored stream.
9895    ///
9896    /// # Errors
9897    ///
9898    /// Returns [`SubscriberError`] when the position is invalid, regresses or
9899    /// conflicts with committed source state, or cannot be restored durably.
9900    fn restore_position(
9901        &mut self,
9902        _position: &SubscriberResumePosition,
9903    ) -> Result<(), SubscriberError> {
9904        Ok(())
9905    }
9906
9907    /// Commit a subscriber-owned delivery token after runtime ingestion.
9908    ///
9909    /// Ephemeral subscribers can rely on this no-op default. Durable remote
9910    /// subscribers should make acknowledgement idempotent because cancellation
9911    /// or transport failure can cause a successfully ingested batch to replay.
9912    /// Re-emitting a token must reproduce the same immutable records, routing,
9913    /// chain controls, chain identity, and provider checkpoint; the checkpointed
9914    /// engine verifies its persisted delivery witness before skipping ingestion.
9915    ///
9916    /// # Errors
9917    ///
9918    /// The returned operation reports [`SubscriberError`] when the delivery
9919    /// token cannot be committed idempotently by the source.
9920    fn acknowledge_delivery(
9921        &mut self,
9922        _token: SubscriberDeliveryToken,
9923    ) -> SubscriberOperation<'_, ()> {
9924        Box::pin(async { Ok(()) })
9925    }
9926}
9927
9928/// Boxed, sendable future returned by subscriber lifecycle operations.
9929///
9930/// The output is generic so the same type can represent registration, removal,
9931/// and future acknowledgement values without requiring an async-trait helper.
9932pub type SubscriberOperation<'a, T> =
9933    Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
9934
9935/// Boxed future returned by [`EventSubscriber::next_batch`].
9936pub type SubscriberNextBatch<'a, N> = Pin<
9937    Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
9938>;
9939
9940/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`].
9941pub type SubscriberNextScopedBatch<'a, N> = Pin<
9942    Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
9943>;
9944
9945/// Subscriber mode requested for the Alloy subscriber.
9946#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9947pub enum SubscriberMode {
9948    /// Prefer the default compiled transport.
9949    ///
9950    /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket
9951    /// subscriptions. Without `reactive-ws`, it resolves to polling only when
9952    /// the opt-in `reactive-polling` feature is enabled.
9953    #[default]
9954    Auto,
9955    /// Use provider pubsub streams.
9956    PubSub,
9957    /// Use polling/watch APIs. Requires the `reactive-polling` feature.
9958    Polling,
9959}
9960
9961#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9962enum FlashblocksAdapter {
9963    NativeSubscriptions,
9964    PendingStatePolling,
9965}
9966
9967fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
9968    match chain_id {
9969        8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
9970        10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
9971        _ => None,
9972    }
9973}
9974
9975/// Subscriber configuration.
9976#[derive(Clone, Debug, PartialEq, Eq)]
9977pub struct SubscriberConfig {
9978    /// Flashblocks delivery policy. Provider support itself is configured by
9979    /// the transport's single `flashblocks` endpoint flag.
9980    pub preconfirmations: PreconfirmationMode,
9981    /// Cadence for certifying sealed canonical heads while connected to a
9982    /// Flashblocks endpoint whose `newHeads` stream may contain partial heads.
9983    pub canonical_head_poll_interval: Duration,
9984    /// Maximum time allowed for one provider request that certifies a
9985    /// canonical head while Flashblocks are active.
9986    pub canonical_head_request_timeout: Duration,
9987    /// Optimism pending-state sampling cadence.
9988    ///
9989    /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers
9990    /// currently expose the interoperable Flashblocks surface through
9991    /// `pending` RPC reads, so one generation-pinned sampler reads the
9992    /// cumulative pending block, its exact hash-addressed parent, filtered
9993    /// pending-block logs, and bounded exact transaction receipts.
9994    pub flashblock_poll_interval: Duration,
9995    /// Consecutive pending-state request failure allowance.
9996    ///
9997    /// A successful sampling tick resets this counter. Semantic integrity
9998    /// failures, such as non-monotonic transaction membership or malformed
9999    /// logs, are never retried through this allowance.
10000    pub max_consecutive_flashblock_poll_failures: usize,
10001    /// Maximum pending receipts per sampling tick.
10002    ///
10003    /// Receipts are requested by exact transaction hash in one JSON-RPC batch,
10004    /// because separate `eth_getBlockReceipts("pending")` responses can refer
10005    /// to a different cumulative Flashblock. The rolling total-method budget
10006    /// may impose a lower effective per-tick limit; with the defaults and one
10007    /// log filter, at most seven receipts are requested per tick.
10008    pub max_pending_transaction_receipts_per_tick: usize,
10009    /// Pending-state RPC method budget per rolling one-second window.
10010    ///
10011    /// The sampler reserves capacity for the pending-block, exact-parent, and
10012    /// filtered-log methods implied by its cadence and filter plan, plus the
10013    /// exact-parent canonical-head poll when block interests require it. Exact
10014    /// receipt hydration uses only an evenly apportioned remainder. Request
10015    /// timestamps enforce the ceiling across actual ticks, including delayed
10016    /// ticks. The default leaves headroom below common paid-provider limits of
10017    /// 50 requests per second.
10018    pub max_flashblock_rpc_requests_per_second: usize,
10019    /// Hydrate pending transaction hashes into full bodies when possible.
10020    pub hydrate_pending_transactions: bool,
10021    /// Verify each canonical log's block identity through RPC and enrich its
10022    /// context with the exact parent hash before delivery.
10023    ///
10024    /// Enable this when a strict coordinator (such as a hybrid historical/live
10025    /// source) must prove canonical ancestry from log-only pubsub events.
10026    /// Verification is cached per block, so the provider is queried at most
10027    /// once for each distinct canonical block retained in the dedupe window.
10028    /// For high-volume pubsub filters, configure
10029    /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP
10030    /// provider so verification responses cannot be starved by notifications.
10031    pub verify_log_block_context: bool,
10032    /// Maximum records to emit per batch.
10033    pub max_batch_size: usize,
10034    /// Maximum distinct contract addresses placed in one provider-side log
10035    /// subscription. Compatible logical owner filters are fanned into address
10036    /// supersets up to this limit; exact owner routing still happens locally.
10037    pub max_log_addresses_per_subscription: usize,
10038    /// Maximum records retained across the delivery queue and hidden
10039    /// transaction-aware reconcile buffer. Exceeding it fails the subscriber
10040    /// closed until a full interest reset, because dropping an event would
10041    /// create an unknowable continuity gap.
10042    pub max_pending_records: usize,
10043    /// Maximum lazy owner-backfill requests retained at once.
10044    pub max_pending_backfills: usize,
10045    /// Maximum approximate encoded bytes accepted from one historical log
10046    /// response (fixed log identity fields, topics, and data).
10047    pub max_backfill_log_bytes: usize,
10048    /// Maximum provider log requests concurrently in flight during bulk owner
10049    /// reconciliation.
10050    pub max_reconcile_requests_in_flight: usize,
10051    /// Reconnect policy for WebSocket/pubsub streams.
10052    pub reconnect: SubscriberReconnectConfig,
10053}
10054
10055impl Default for SubscriberConfig {
10056    fn default() -> Self {
10057        Self {
10058            preconfirmations: PreconfirmationMode::Disabled,
10059            canonical_head_poll_interval: Duration::from_millis(500),
10060            canonical_head_request_timeout: Duration::from_secs(3),
10061            flashblock_poll_interval: Duration::from_millis(250),
10062            max_consecutive_flashblock_poll_failures: 10,
10063            max_pending_transaction_receipts_per_tick: 32,
10064            max_flashblock_rpc_requests_per_second: 40,
10065            hydrate_pending_transactions: false,
10066            verify_log_block_context: false,
10067            max_batch_size: 1024,
10068            max_log_addresses_per_subscription: 1024,
10069            max_pending_records: 16_384,
10070            max_pending_backfills: 4_096,
10071            max_backfill_log_bytes: 64 * 1024 * 1024,
10072            max_reconcile_requests_in_flight: 8,
10073            reconnect: SubscriberReconnectConfig::default(),
10074        }
10075    }
10076}
10077
10078/// Provider surface established for one Flashblocks generation.
10079#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10080pub enum FlashblocksDelivery {
10081    /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams.
10082    NativeSubscriptions,
10083    /// Generation-pinned `pending` block and log sampling.
10084    PendingStatePolling,
10085    /// Standardized updates supplied by an application-managed transport.
10086    #[cfg(feature = "raw-flashblocks-json")]
10087    ExternalUpdates,
10088}
10089
10090/// Request/response traffic issued by one Flashblocks subscriber generation.
10091#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10092pub struct FlashblocksRpcMetrics {
10093    capability_requests: u64,
10094    provider_pair_chain_requests: u64,
10095    canonical_head_requests: u64,
10096    pending_block_requests: u64,
10097    pending_log_requests: u64,
10098    pending_receipt_requests: u64,
10099    pending_receipts_completed: u64,
10100    pending_receipts_unavailable: u64,
10101    failed_requests: u64,
10102    raced_samples: u64,
10103}
10104
10105impl FlashblocksRpcMetrics {
10106    /// Opportunistic `op_supportedCapabilities` probes attempted.
10107    pub const fn capability_requests(self) -> u64 {
10108        self.capability_requests
10109    }
10110
10111    /// Chain-identity requests used to verify an explicitly paired
10112    /// pending-state provider against the subscriber's stream provider.
10113    pub const fn provider_pair_chain_requests(self) -> u64 {
10114        self.provider_pair_chain_requests
10115    }
10116
10117    /// Exact parent-block requests used to fence pending and canonical state.
10118    pub const fn canonical_head_requests(self) -> u64 {
10119        self.canonical_head_requests
10120    }
10121
10122    /// Cumulative pending-block requests.
10123    pub const fn pending_block_requests(self) -> u64 {
10124        self.pending_block_requests
10125    }
10126
10127    /// Pending log-filter requests.
10128    pub const fn pending_log_requests(self) -> u64 {
10129        self.pending_log_requests
10130    }
10131
10132    /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash.
10133    /// Several methods may share one JSON-RPC batch transport request.
10134    pub const fn pending_receipt_requests(self) -> u64 {
10135        self.pending_receipt_requests
10136    }
10137
10138    /// Exact pending transaction receipts returned successfully.
10139    pub const fn pending_receipts_completed(self) -> u64 {
10140        self.pending_receipts_completed
10141    }
10142
10143    /// Exact pending transaction receipts that were not materialized yet and remain
10144    /// eligible for retry on the next cumulative sample.
10145    pub const fn pending_receipts_unavailable(self) -> u64 {
10146        self.pending_receipts_unavailable
10147    }
10148
10149    /// Provider request failures observed by a pending-state sampler.
10150    pub const fn failed_requests(self) -> u64 {
10151        self.failed_requests
10152    }
10153
10154    /// Samples discarded because the pending-log response advanced beyond
10155    /// the separately fetched cumulative block. The next tick retries from a
10156    /// fresh block/log pair; no partial speculative view is published.
10157    pub const fn raced_samples(self) -> u64 {
10158        self.raced_samples
10159    }
10160
10161    /// Total request/response calls attributable to Flashblocks qualification
10162    /// and sampling.
10163    pub const fn total_requests(self) -> u64 {
10164        self.capability_requests
10165            .saturating_add(self.provider_pair_chain_requests)
10166            .saturating_add(self.canonical_head_requests)
10167            .saturating_add(self.pending_block_requests)
10168            .saturating_add(self.pending_log_requests)
10169            .saturating_add(self.pending_receipt_requests)
10170    }
10171}
10172
10173/// Successful initial Flashblocks endpoint preflight.
10174///
10175/// This proves chain identity and either subscription acknowledgement for
10176/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, method
10177/// support for OP's bounded pending block/log sampler, or the canonical stream
10178/// topology paired with an application-managed standardized source.
10179/// Notification liveness and active-interest coverage remain acceptance-window
10180/// checks: a successful preflight alone must not qualify a source for live use.
10181#[derive(Clone, Debug, PartialEq, Eq)]
10182pub struct FlashblocksPreflight {
10183    chain_id: u64,
10184    provider: ProviderRef,
10185    delivery: FlashblocksDelivery,
10186    pending_log_subscriptions: usize,
10187    pending_log_filters: usize,
10188    advertised_capabilities: Option<serde_json::Value>,
10189}
10190
10191impl FlashblocksPreflight {
10192    /// Chain identity read from the pinned provider lease.
10193    pub const fn chain_id(&self) -> u64 {
10194        self.chain_id
10195    }
10196
10197    /// Provider generation selected for speculative updates.
10198    ///
10199    /// Built-in profiles preflight this provider's coupled request/subscription
10200    /// surfaces. External profiles retain caller-supplied provenance while the
10201    /// application qualifies the supplemental socket separately.
10202    pub const fn provider(&self) -> &ProviderRef {
10203        &self.provider
10204    }
10205
10206    /// Provider surface selected for this chain.
10207    pub const fn delivery(&self) -> FlashblocksDelivery {
10208        self.delivery
10209    }
10210
10211    /// Number of acknowledged pool-filtered `pendingLogs` subscriptions.
10212    ///
10213    /// This is zero for sampled and externally managed delivery profiles.
10214    pub const fn pending_log_subscriptions(&self) -> usize {
10215        self.pending_log_subscriptions
10216    }
10217
10218    /// Number of provider-facing log filters whose interests must be covered by
10219    /// the selected native, sampled, or external delivery surface.
10220    pub const fn pending_log_filters(&self) -> usize {
10221        self.pending_log_filters
10222    }
10223
10224    /// Opaque response from `op_supportedCapabilities`, when the provider
10225    /// implements that optional RPC method.
10226    pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
10227        self.advertised_capabilities.as_ref()
10228    }
10229}
10230
10231/// WebSocket/pubsub reconnect policy.
10232///
10233/// Reconnects are applied after an established subscription stream terminates.
10234/// Initial subscription failures are still returned immediately so deployment
10235/// mistakes, unsupported transports, and bad endpoints fail fast.
10236#[derive(Clone, Debug, PartialEq, Eq)]
10237pub struct SubscriberReconnectConfig {
10238    /// Whether pubsub streams should be recreated after termination.
10239    pub enabled: bool,
10240    /// Delay before the first reconnect attempt.
10241    pub initial_delay: Duration,
10242    /// Delay before the second reconnect attempt. Later retries double this
10243    /// delay up to [`Self::max_delay`].
10244    pub retry_delay: Duration,
10245    /// Maximum delay between reconnect attempts.
10246    pub max_delay: Duration,
10247    /// Maximum reconnect attempts per terminated stream. `None` retries forever.
10248    pub max_attempts: Option<usize>,
10249    /// Number of recently emitted canonical input refs remembered to suppress
10250    /// duplicates across reconnect backfill and subscription replay.
10251    pub dedupe_window: usize,
10252}
10253
10254impl Default for SubscriberReconnectConfig {
10255    fn default() -> Self {
10256        Self {
10257            enabled: true,
10258            initial_delay: Duration::ZERO,
10259            retry_delay: Duration::from_millis(250),
10260            max_delay: Duration::from_secs(30),
10261            max_attempts: Some(3),
10262            dedupe_window: 4096,
10263        }
10264    }
10265}
10266
10267/// Historical log backfill requested when adding subscriber interests.
10268///
10269/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and
10270/// pending-transaction interests are live-only. `AlloySubscriber` emits records
10271/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe
10272/// owner registration adopts/subscribes the desired live filter first, then
10273/// reconciles history behind that live fence; startup/global replacement commits
10274/// topology and historical work as one desired-state transaction. A drained
10275/// backfill seeds the filter's delivery anchor at its resolved upper bound (even
10276/// when the window held no logs), so the newly added filter gets the same
10277/// reconnect/catch-up protection an established one has.
10278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10279pub struct SubscriberBackfill {
10280    from_block: u64,
10281    to_block: Option<u64>,
10282    retained_anchor: Option<BlockRef>,
10283}
10284
10285impl SubscriberBackfill {
10286    /// Backfill an inclusive block range.
10287    pub fn range(from_block: u64, to_block: u64) -> Self {
10288        Self {
10289            from_block,
10290            to_block: Some(to_block),
10291            retained_anchor: None,
10292        }
10293    }
10294
10295    /// Backfill from `from_block` through the provider's latest block.
10296    pub fn from_block(from_block: u64) -> Self {
10297        Self {
10298            from_block,
10299            to_block: None,
10300            retained_anchor: None,
10301        }
10302    }
10303
10304    /// Backfill inclusively from an exact retained canonical block.
10305    ///
10306    /// The Alloy subscriber verifies this number/hash against its provider
10307    /// before accepting any lazy catch-up response. Engine-managed mid-stream
10308    /// registration uses this form so owner replay cannot silently cross a
10309    /// reorged discovery boundary.
10310    pub fn from_canonical_block(block: BlockRef) -> Self {
10311        Self {
10312            from_block: block.number,
10313            to_block: None,
10314            retained_anchor: Some(block),
10315        }
10316    }
10317
10318    /// Backfill inclusively from an exact canonical block through an inclusive
10319    /// upper bound.
10320    ///
10321    /// # Errors
10322    ///
10323    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10324    /// retained anchor.
10325    pub fn from_canonical_block_through(
10326        block: BlockRef,
10327        to_block: u64,
10328    ) -> Result<Self, SubscriberError> {
10329        if to_block < block.number {
10330            return Err(SubscriberError::InvalidConfig(
10331                "inclusive backfill upper bound precedes its retained anchor",
10332            ));
10333        }
10334        Ok(Self {
10335            from_block: block.number,
10336            to_block: Some(to_block),
10337            retained_anchor: Some(block),
10338        })
10339    }
10340
10341    /// Backfill strictly after an exact canonical state baseline.
10342    ///
10343    /// This is distinct from [`from_canonical_block`](Self::from_canonical_block):
10344    /// a restored cache already embodies every effect through `block`, so
10345    /// replaying that block would apply it twice. The retained block is still
10346    /// carried so the subscriber can prove that its provider is on the same
10347    /// canonical branch before accepting any post-baseline history.
10348    ///
10349    /// Returns an error at `u64::MAX`; silently saturating would turn an empty
10350    /// exclusive range into an inclusive replay of the baseline block.
10351    ///
10352    /// # Errors
10353    ///
10354    /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is
10355    /// `u64::MAX` and therefore has no following block.
10356    pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
10357        Self::after_canonical_block_inner(block, None)
10358    }
10359
10360    /// Backfill strictly after an exact canonical baseline through an
10361    /// inclusive upper bound.
10362    ///
10363    /// `to_block == block.number` represents a deliberately empty certified
10364    /// interval. Bounds before the retained baseline are rejected.
10365    ///
10366    /// # Errors
10367    ///
10368    /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the
10369    /// baseline, or when a non-empty exclusive range would have to begin after
10370    /// block `u64::MAX`.
10371    pub fn after_canonical_block_through(
10372        block: BlockRef,
10373        to_block: u64,
10374    ) -> Result<Self, SubscriberError> {
10375        if to_block < block.number {
10376            return Err(SubscriberError::InvalidConfig(
10377                "exclusive backfill upper bound precedes its retained baseline",
10378            ));
10379        }
10380        Self::after_canonical_block_inner(block, Some(to_block))
10381    }
10382
10383    fn after_canonical_block_inner(
10384        block: BlockRef,
10385        to_block: Option<u64>,
10386    ) -> Result<Self, SubscriberError> {
10387        let from_block = block
10388            .number
10389            .checked_add(1)
10390            .ok_or(SubscriberError::InvalidConfig(
10391                "cannot construct an exclusive backfill after block u64::MAX",
10392            ))?;
10393        Ok(Self {
10394            from_block,
10395            to_block,
10396            retained_anchor: Some(block),
10397        })
10398    }
10399
10400    /// First block included in the backfill.
10401    pub fn start_block(&self) -> u64 {
10402        self.from_block
10403    }
10404
10405    /// Last block included in the backfill, or `None` for provider latest.
10406    pub fn end_block(&self) -> Option<u64> {
10407        self.to_block
10408    }
10409
10410    /// Exact retained start-block identity, when supplied.
10411    pub fn retained_anchor(&self) -> Option<&BlockRef> {
10412        self.retained_anchor.as_ref()
10413    }
10414}
10415
10416/// Opaque generation for one transaction-aware subscriber interest owner.
10417///
10418/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never
10419/// reused, including after an aborted stage or a full interest replacement.
10420/// Lifecycle operations require the complete token so a delayed command for an
10421/// older registration cannot affect a replacement using the same [`HandlerId`].
10422#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
10423pub struct SubscriberOwnerEpoch {
10424    owner: HandlerId,
10425    sequence: u64,
10426}
10427
10428/// Delivery audience retained with a subscriber input record.
10429///
10430/// Canonical inputs are forwarded once to the runtime actor and may also name
10431/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or
10432/// overlap records that must never be routed through existing canonical
10433/// handlers.
10434#[derive(Clone, Debug, PartialEq, Eq)]
10435#[non_exhaustive]
10436pub enum SubscriberInputScope {
10437    /// One canonical input plus any staged owners that matched at enqueue time.
10438    Canonical {
10439        /// Staged owner epochs that require a buffered copy.
10440        owners: Vec<SubscriberOwnerEpoch>,
10441    },
10442    /// Canonical input whose owner catch-up already delivered selected handler
10443    /// owners. The residual canonical copy must exclude those handlers while
10444    /// remaining authoritative for global chain progress.
10445    CanonicalResidual {
10446        /// Staged epoch owners that still require a buffered copy.
10447        owners: Vec<SubscriberOwnerEpoch>,
10448        /// Active compatibility owners already served by owner catch-up.
10449        excluded: Vec<HandlerId>,
10450    },
10451    /// Input delivered only to the listed staged owners.
10452    OwnerOnly {
10453        /// Exact staged owner epochs receiving the input.
10454        owners: Vec<SubscriberOwnerEpoch>,
10455    },
10456    /// Compatibility owner-only delivery keyed by stable handler id.
10457    OwnerOnlyHandlers {
10458        /// Exact active handlers receiving the catch-up input.
10459        owners: Vec<HandlerId>,
10460    },
10461    /// Flashblock input routed through ordinary matching handlers but applied
10462    /// only to the speculative overlay.
10463    Preconfirmed,
10464}
10465
10466impl SubscriberInputScope {
10467    /// Exact staged owner epochs attached to this input.
10468    pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
10469        match self {
10470            Self::Canonical { owners }
10471            | Self::CanonicalResidual { owners, .. }
10472            | Self::OwnerOnly { owners } => owners,
10473            Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
10474        }
10475    }
10476
10477    /// Whether this input must be forwarded once through canonical routing.
10478    pub const fn is_canonical(&self) -> bool {
10479        matches!(
10480            self,
10481            Self::Canonical { .. } | Self::CanonicalResidual { .. }
10482        )
10483    }
10484
10485    /// Whether this input belongs only to the disposable preconfirmed overlay.
10486    pub const fn is_preconfirmed(&self) -> bool {
10487        matches!(self, Self::Preconfirmed)
10488    }
10489}
10490
10491/// Reactive input together with its canonical/owner-scoped delivery audience.
10492#[derive(Clone, Debug)]
10493pub struct SubscriberInputRecord<N: Network = Ethereum> {
10494    record: ReactiveInputRecord<N>,
10495    scope: SubscriberInputScope,
10496}
10497
10498impl<N: Network> SubscriberInputRecord<N> {
10499    /// Borrow the reactive input record.
10500    pub const fn record(&self) -> &ReactiveInputRecord<N> {
10501        &self.record
10502    }
10503
10504    /// Delivery audience captured when the record was enqueued.
10505    pub const fn scope(&self) -> &SubscriberInputScope {
10506        &self.scope
10507    }
10508
10509    /// Consume the scoped value into its reactive input record.
10510    pub fn into_record(self) -> ReactiveInputRecord<N> {
10511        self.record
10512    }
10513}
10514
10515impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
10516    type Target = ReactiveInputRecord<N>;
10517
10518    fn deref(&self) -> &Self::Target {
10519        &self.record
10520    }
10521}
10522
10523/// Batch of subscriber inputs with enqueue-time owner provenance.
10524#[derive(Clone, Debug)]
10525pub struct SubscriberInputBatch<N: Network = Ethereum> {
10526    records: Vec<SubscriberInputRecord<N>>,
10527    chain_id: Option<u64>,
10528    chain_controls: Vec<ChainControl>,
10529    preconfirmation_invalidated: bool,
10530}
10531
10532/// Result of polling a scoped subscriber batch against one driver control
10533/// future.
10534#[derive(Debug)]
10535#[non_exhaustive]
10536pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
10537    /// The control future completed first; subscriber delivery remains intact.
10538    Control(C),
10539    /// Subscriber polling completed first.
10540    Batch(Option<SubscriberInputBatch<N>>),
10541}
10542
10543impl<N: Network> SubscriberInputBatch<N> {
10544    /// Borrow every scoped record in delivery order.
10545    pub fn records(&self) -> &[SubscriberInputRecord<N>] {
10546        &self.records
10547    }
10548
10549    /// Consume the batch into its scoped records.
10550    pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
10551        self.records
10552    }
10553
10554    /// Ordered chain controls committed after the preceding records.
10555    pub fn chain_controls(&self) -> &[ChainControl] {
10556        &self.chain_controls
10557    }
10558
10559    /// Whether the announcing Flashblocks generation lost continuity before
10560    /// this batch was returned.
10561    pub const fn preconfirmation_invalidated(&self) -> bool {
10562        self.preconfirmation_invalidated
10563    }
10564
10565    /// Consume the scoped subscriber delivery into a runtime-ready batch.
10566    ///
10567    /// Delivery audiences and the preconfirmed/canonical boundary are retained,
10568    /// allowing downstream owner actors to forward a batch without rebuilding
10569    /// subscriber-internal scope metadata.
10570    pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
10571        let chain_id = self.chain_id;
10572        let chain_controls = self.chain_controls;
10573        let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
10574            self.records.into_iter().map(|scoped| {
10575                let source = scoped.record.context.source;
10576                let (audience, delivery_scope) = match scoped.scope {
10577                    SubscriberInputScope::Canonical { .. } => (
10578                        DeliveryAudience::All,
10579                        if source == InputSource::Backfill {
10580                            DeliveryScope::CanonicalProgress
10581                        } else {
10582                            DeliveryScope::Canonical
10583                        },
10584                    ),
10585                    SubscriberInputScope::CanonicalResidual { excluded, .. } => (
10586                        DeliveryAudience::AllExcept(excluded),
10587                        if source == InputSource::Backfill {
10588                            DeliveryScope::CanonicalProgress
10589                        } else {
10590                            DeliveryScope::Canonical
10591                        },
10592                    ),
10593                    SubscriberInputScope::OwnerOnly { owners } => {
10594                        let mut handler_ids = Vec::with_capacity(owners.len());
10595                        for epoch in owners {
10596                            if !handler_ids.contains(epoch.owner()) {
10597                                handler_ids.push(epoch.owner().clone());
10598                            }
10599                        }
10600                        (
10601                            DeliveryAudience::Owners(handler_ids),
10602                            DeliveryScope::OwnerCatchup,
10603                        )
10604                    }
10605                    SubscriberInputScope::OwnerOnlyHandlers { owners } => (
10606                        DeliveryAudience::Owners(owners),
10607                        DeliveryScope::OwnerCatchup,
10608                    ),
10609                    SubscriberInputScope::Preconfirmed => {
10610                        (DeliveryAudience::All, DeliveryScope::Preconfirmed)
10611                    }
10612                };
10613                (scoped.record, audience, delivery_scope)
10614            }),
10615        )
10616        .with_chain_controls(chain_controls);
10617        if let Some(chain_id) = chain_id {
10618            batch = batch.with_chain_id(chain_id);
10619        }
10620        batch
10621    }
10622}
10623
10624impl SubscriberOwnerEpoch {
10625    /// Logical subscriber owner represented by this epoch.
10626    pub const fn owner(&self) -> &HandlerId {
10627        &self.owner
10628    }
10629
10630    /// Monotonic subscriber-local epoch sequence.
10631    pub const fn sequence(&self) -> u64 {
10632        self.sequence
10633    }
10634}
10635
10636/// Catch-up policy applied when staging a transaction-aware interest owner.
10637#[derive(Clone, Debug, PartialEq, Eq)]
10638#[non_exhaustive]
10639pub enum SubscriberOwnerStart {
10640    /// Start with live delivery only.
10641    Live,
10642    /// Start strictly after an already-applied post-block baseline.
10643    ///
10644    /// A baseline at block `N` schedules backfill from `N + 1`; block `N`
10645    /// itself is never replayed. Transaction-aware callers explicitly call
10646    /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged
10647    /// owners never use the legacy lazy-backfill queue.
10648    PostBlock(BlockRef),
10649}
10650
10651/// Transaction state of one epoch-scoped subscriber owner.
10652#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10653#[non_exhaustive]
10654pub enum SubscriberOwnerState {
10655    /// Desired interests and owner-scoped buffering are installed but canonical
10656    /// routing has not yet committed.
10657    Staged,
10658    /// Canonical runtime routing has committed for this owner.
10659    Active,
10660    /// Removal is prepared behind a delivery fence but remains reversible.
10661    Removing,
10662}
10663
10664/// Hash-certified catch-up position reached by one subscriber owner epoch.
10665///
10666/// Progress means every owner-only record through this point has been fetched
10667/// and queued inside the subscriber. It does not mean the downstream actor has
10668/// drained or committed those records; that requires a separate delivery fence.
10669#[derive(Clone, Debug, PartialEq, Eq)]
10670pub struct SubscriberOwnerProgress {
10671    owner: SubscriberOwnerEpoch,
10672    through: BlockRef,
10673}
10674
10675impl SubscriberOwnerProgress {
10676    /// Exact owner epoch whose catch-up was reconciled.
10677    pub const fn owner(&self) -> &SubscriberOwnerEpoch {
10678        &self.owner
10679    }
10680
10681    /// Verified canonical block through which owner input was fetched.
10682    pub const fn through(&self) -> &BlockRef {
10683        &self.through
10684    }
10685}
10686
10687/// Error staging a transaction-aware subscriber owner.
10688#[derive(Debug, thiserror::Error)]
10689#[non_exhaustive]
10690pub enum SubscriberOwnerError {
10691    /// Subscriber configuration or interest validation failed.
10692    #[error(transparent)]
10693    Subscriber(#[from] SubscriberError),
10694    /// The logical owner already has desired interests installed.
10695    #[error("subscriber interest owner `{0}` is already registered")]
10696    AlreadyRegistered(HandlerId),
10697    /// A post-block baseline cannot be advanced to its first unapplied block.
10698    #[error("post-block subscriber baseline {0} has no following block")]
10699    PostBlockOverflow(u64),
10700    /// The monotonic subscriber owner epoch sequence was exhausted.
10701    #[error("subscriber owner epoch sequence exhausted")]
10702    EpochExhausted,
10703    /// The exact owner epoch is unknown or no longer staged.
10704    #[error("subscriber owner epoch is not staged")]
10705    NotStaged,
10706    /// Live-only staging has no historical baseline to reconcile.
10707    #[error("subscriber owner was staged live-only and has no catch-up baseline")]
10708    MissingBaseline,
10709    /// Post-block reconciliation currently covers log interests only.
10710    #[error("post-block subscriber owners support log interests only")]
10711    UnsupportedPostBlockInterest,
10712    /// The target block was absent from the provider.
10713    #[error("subscriber reconcile target block {0} was not found")]
10714    BlockUnavailable(u64),
10715    /// The provider's canonical identity did not match the requested target.
10716    #[error(
10717        "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
10718    )]
10719    BlockMismatch {
10720        /// Requested block number.
10721        expected_number: u64,
10722        /// Requested block hash.
10723        expected_hash: B256,
10724        /// Provider block number.
10725        actual_number: u64,
10726        /// Provider block hash.
10727        actual_hash: B256,
10728    },
10729    /// A reconcile target was older than the retained baseline/progress.
10730    #[error("subscriber reconcile target block {target} precedes current owner position {current}")]
10731    ProgressRegression {
10732        /// Retained baseline or progress block.
10733        current: u64,
10734        /// Rejected target block.
10735        target: u64,
10736    },
10737    /// A reconcile attempted to replace a retained block identity at the same
10738    /// height or cross an immediate parent that does not extend it.
10739    #[error(
10740        "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
10741    )]
10742    ProgressConflict {
10743        /// Retained baseline or progress block number.
10744        number: u64,
10745        /// Retained baseline or progress block hash.
10746        current_hash: B256,
10747        /// Conflicting target hash or immediate parent hash.
10748        target_hash: B256,
10749    },
10750    /// A provider returned a malformed or out-of-range catch-up log.
10751    #[error("subscriber reconcile returned an invalid catch-up log: {0}")]
10752    InvalidBackfillLog(&'static str),
10753}
10754
10755/// Extension trait for subscribers that can add and remove handler-owned
10756/// interests incrementally.
10757///
10758/// [`EventSubscriber::register_interests`] remains the full-replacement setup
10759/// API. Implement this trait when a subscriber can preserve unrelated live
10760/// sources and delivery state while one handler's interests are added or
10761/// removed. Implementations should make owner *replacement* continuity-safe:
10762/// updating an owner's interests must not silently discard delivery progress
10763/// the previous interests had already established (the in-crate
10764/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to
10765/// changed filter shapes and automatically backfills the gap). Every mutating
10766/// operation is also a commit boundary: returning `Ok` means the new desired
10767/// state is authoritative, while errors or cancellation must preserve the
10768/// previous state or reconcile before exposing the uncommitted change.
10769pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
10770    /// Atomically add or replace several owners in one desired-state revision.
10771    ///
10772    /// Unrelated owners remain installed. Returning `Ok(())` is one commit
10773    /// boundary for the complete set; an error or cancellation must leave the
10774    /// previously committed owner topology authoritative. Durable remote
10775    /// subscribers should override this method so bootstrap creates one service
10776    /// revision and one activation barrier rather than one barrier per owner.
10777    ///
10778    /// # Errors
10779    ///
10780    /// The returned operation reports [`SubscriberError::Unsupported`] by
10781    /// default, or an implementation-specific validation or commit failure.
10782    fn upsert_interest_owners(
10783        &mut self,
10784        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10785    ) -> SubscriberOperation<'_, ()> {
10786        Box::pin(async {
10787            Err(SubscriberError::Unsupported(
10788                "subscriber does not implement atomic bulk owner upsert",
10789            ))
10790        })
10791    }
10792
10793    /// Atomically replace the complete engine-managed owner topology without
10794    /// requesting history.
10795    ///
10796    /// This is the fresh-runtime bootstrap operation. Base/unowned interests,
10797    /// stale owners, queued delivery, and dedupe/source state from the prior
10798    /// topology must not survive a successful replacement. Errors and dropped
10799    /// futures leave the prior committed topology authoritative.
10800    ///
10801    /// # Errors
10802    ///
10803    /// The returned operation reports [`SubscriberError::Unsupported`] by
10804    /// default, or an implementation-specific validation or commit failure.
10805    fn replace_interest_owners(
10806        &mut self,
10807        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10808    ) -> SubscriberOperation<'_, ()> {
10809        Box::pin(async {
10810            Err(SubscriberError::Unsupported(
10811                "subscriber does not implement atomic exact owner replacement",
10812            ))
10813        })
10814    }
10815
10816    /// Atomically replace the complete owner set and schedule one global
10817    /// historical log backfill in the same desired-state revision.
10818    ///
10819    /// This is the continuity-safe bootstrap operation for a runtime that has
10820    /// already processed canonical state while the subscriber's owner state is
10821    /// new or may have been lost. Implementations must commit the complete
10822    /// owner topology and all required historical work together: returning an
10823    /// error or dropping the future must leave the previously committed state
10824    /// authoritative. The default is deliberately unsupported rather than a
10825    /// sequence of partially committed single-owner updates.
10826    /// Historical records must be delivered through canonical global routing
10827    /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as
10828    /// owner catch-up, so their effects participate in the normal rollback
10829    /// journal before the source certifies the cutover. Base/unowned interests
10830    /// are replaced by this complete engine-managed topology. Any owner absent
10831    /// from `owners` must be removed together with its queued owner-only work, which closes
10832    /// the crash window where a subscriber committed registration but the
10833    /// runtime process died before installing the corresponding handler.
10834    ///
10835    /// # Errors
10836    ///
10837    /// The returned operation reports [`SubscriberError::Unsupported`] by
10838    /// default, or a backfill, validation, transport, or atomic-commit failure.
10839    fn replace_interest_owners_with_global_backfill(
10840        &mut self,
10841        _owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
10842        _backfill: SubscriberBackfill,
10843    ) -> SubscriberOperation<'_, ()> {
10844        Box::pin(async {
10845            Err(SubscriberError::Unsupported(
10846                "subscriber does not implement atomic owner replacement with global backfill",
10847            ))
10848        })
10849    }
10850
10851    /// Add or replace the interests owned by `owner`, awaiting the subscriber's
10852    /// commit boundary.
10853    ///
10854    /// Implementations must leave the previously committed owner state
10855    /// authoritative when the operation returns an error or is cancelled before
10856    /// completion.
10857    ///
10858    /// # Errors
10859    ///
10860    /// The returned operation reports [`SubscriberError`] when the owner update
10861    /// cannot be validated or committed.
10862    fn add_interest_owner(
10863        &mut self,
10864        owner: HandlerId,
10865        interests: &[ReactiveInterest<N>],
10866    ) -> SubscriberOperation<'_, ()>;
10867
10868    /// Add or replace owner interests and schedule log backfill for that owner,
10869    /// awaiting the subscriber's commit boundary.
10870    ///
10871    /// # Errors
10872    ///
10873    /// The returned operation reports [`SubscriberError`] when the owner update
10874    /// or requested backfill cannot be validated or committed.
10875    fn add_interest_owner_with_backfill(
10876        &mut self,
10877        owner: HandlerId,
10878        interests: &[ReactiveInterest<N>],
10879        backfill: SubscriberBackfill,
10880    ) -> SubscriberOperation<'_, ()>;
10881
10882    /// Add a handler discovered at retained canonical block `C` without
10883    /// opening a gap while registration commits.
10884    ///
10885    /// The subscriber must subscribe/adopt the new desired state first, then
10886    /// expose the new owner's matching records from `C` as owner catch-up and
10887    /// expose `C + 1` through the activation head as one globally ordered
10888    /// canonical catch-up over the complete active interest union. This split
10889    /// is deliberate: the runtime already has a rollback entry for `C`, while
10890    /// later blocks must run every handler and create normal canonical journal
10891    /// entries. Errors/cancellation preserve the prior committed topology.
10892    /// Implementations that cannot uphold this coordinated transaction must
10893    /// return `Unsupported`; emitting owner-only records past `C` is invalid.
10894    ///
10895    /// # Errors
10896    ///
10897    /// The returned operation reports [`SubscriberError::Unsupported`] by
10898    /// default, or a canonical-anchor, transport, or atomic-commit failure.
10899    fn add_interest_owner_with_canonical_catchup(
10900        &mut self,
10901        _owner: HandlerId,
10902        _interests: &[ReactiveInterest<N>],
10903        _retained: BlockRef,
10904    ) -> SubscriberOperation<'_, ()> {
10905        Box::pin(async {
10906            Err(SubscriberError::Unsupported(
10907                "subscriber does not implement coordinated canonical owner catch-up",
10908            ))
10909        })
10910    }
10911
10912    /// Remove one owner's interests, preserving unrelated interests, and await
10913    /// acknowledgement that the removal committed.
10914    ///
10915    /// On error the owner must remain authoritative, so the runtime handler is
10916    /// not removed while subscriber delivery may still target it.
10917    ///
10918    /// # Errors
10919    ///
10920    /// The returned operation reports [`SubscriberError`] when the removal
10921    /// cannot be committed while preserving unrelated owners.
10922    fn remove_interest_owner(
10923        &mut self,
10924        owner: &HandlerId,
10925    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>>;
10926
10927    /// Borrow the interests currently owned by `owner`.
10928    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
10929}
10930
10931/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common
10932/// subscribe-ingest lifecycle.
10933///
10934/// The engine treats the runtime registry as the single source of truth for
10935/// handler lifecycle: [`register_handler`](Self::register_handler) and
10936/// [`unregister_handler`](Self::unregister_handler) update runtime routing and
10937/// subscriber interests as one operation, keyed by the handler's stable
10938/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime
10939/// has journaled canonical block *N*, a newly registered handler is live-adopted,
10940/// replayed owner-only at *N*, and then caught up globally with every handler
10941/// from *N + 1* through activation. A factory-discovered pool therefore misses
10942/// none of its own logs without making later history owner-local and
10943/// unrollbackable. The subscriber must absorb overlap that crosses batch
10944/// boundaries; the runtime validates and merges duplicate representations only
10945/// within one [`ReactiveInputBatch`].
10946///
10947/// Registration methods by intent:
10948///
10949/// | Method | Backfill |
10950/// |---|---|
10951/// | [`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) |
10952/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal |
10953/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only |
10954///
10955/// Unregistering a handler stops future subscription routing and runtime
10956/// decode for that handler; it deliberately does not evict [`EvmCache`] state
10957/// or undo runtime side effects. See
10958/// [`unregister_handler`](Self::unregister_handler) for the complete teardown
10959/// recipe.
10960///
10961/// The runtime and subscriber stay independently accessible through
10962/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut)
10963/// for advanced use. One caution: avoid calling
10964/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on
10965/// an engine-managed subscriber — implementations may clear owner-scoped
10966/// bookkeeping, after which per-handler unregistration no longer releases the
10967/// handler's transport subscriptions. To bootstrap the subscriber from a
10968/// runtime that already has handlers, use
10969/// [`sync_handler_interests`](Self::sync_handler_interests), which registers
10970/// one owner per handler instead of one unowned blob.
10971pub struct ReactiveEngine<S, N: Network = Ethereum> {
10972    runtime: ReactiveRuntime<N>,
10973    subscriber: S,
10974    pending_acknowledgement: Option<PendingAcknowledgement<N>>,
10975    pending_checkpoint: Option<PendingCheckpoint<N>>,
10976    last_checkpoint_block: Option<DurableCheckpointBlock>,
10977    last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
10978    last_checkpoint_delivery_witness: Option<B256>,
10979    last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
10980    checkpoint_identity: Option<DurableCheckpointIdentity>,
10981}
10982
10983struct PendingAcknowledgement<N: Network> {
10984    token: SubscriberDeliveryToken,
10985    report: ReactiveBatchReport<N>,
10986}
10987
10988struct PendingCheckpoint<N: Network> {
10989    metadata: DurableCheckpointMetadata,
10990    delivery_token: Option<SubscriberDeliveryToken>,
10991    report: ReactiveBatchReport<N>,
10992    saved_to: Option<PathBuf>,
10993    staged_generation: u64,
10994}
10995
10996struct CheckpointStage<N: Network> {
10997    incoming_block: Option<DurableCheckpointBlock>,
10998    delivery_token: Option<SubscriberDeliveryToken>,
10999    delivery_witness: Option<B256>,
11000    subscriber_checkpoint: Option<SubscriberCheckpoint>,
11001    staged_generation: u64,
11002    report: ReactiveBatchReport<N>,
11003}
11004
11005struct DurableResumePlan {
11006    runtime: DurableRuntimeRestorePlan,
11007    position: SubscriberResumePosition,
11008    delivery_witness: Option<B256>,
11009}
11010
11011enum HandlerRegistrationCatchup {
11012    LiveOnly,
11013    OwnerBackfill(SubscriberBackfill),
11014    CoordinatedCanonical(BlockRef),
11015}
11016
11017const DELIVERY_WITNESS_VERSION: u32 = 1;
11018const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
11019
11020#[derive(serde::Serialize)]
11021struct DeliveryWitnessEnvelope<'a> {
11022    version: u32,
11023    chain_id: Option<u64>,
11024    records: Vec<DeliveryRecordWitness<'a>>,
11025    chain_controls: &'a [ChainControl],
11026    subscriber_checkpoint: Option<&'a [u8]>,
11027    payload_commitment: Option<B256>,
11028}
11029
11030#[derive(serde::Serialize)]
11031struct DeliveryRecordWitness<'a> {
11032    identity: ReactiveInputIdentity,
11033    context: &'a ReactiveContext,
11034    audience: &'a DeliveryAudience,
11035    scope: DeliveryScope,
11036    payload: DeliveryPayloadWitness<'a>,
11037}
11038
11039#[derive(serde::Serialize)]
11040enum DeliveryPayloadWitness<'a> {
11041    /// Logs are the primary state-bearing event representation, so retain every
11042    /// RPC payload field in addition to the validated identity/context.
11043    Log {
11044        address: Address,
11045        topics: &'a [B256],
11046        data: &'a Bytes,
11047        block_hash: Option<B256>,
11048        block_number: Option<u64>,
11049        block_timestamp: Option<u64>,
11050        transaction_hash: Option<B256>,
11051        transaction_index: Option<u64>,
11052        log_index: Option<u64>,
11053        removed: bool,
11054    },
11055    /// Network-generic response bodies do not expose one stable complete serde
11056    /// contract. Their validated identity/context are witnessed here; batches
11057    /// containing headers, full blocks, or hydrated transactions additionally
11058    /// require the source's exact canonical wire-payload commitment. A generic
11059    /// header response can expose a supplied hash without proving that every
11060    /// handler-visible inner field recomputes to it.
11061    IdentityCommitted,
11062}
11063
11064fn durable_delivery_witness<N: Network>(
11065    batch: &ReactiveInputBatch<N>,
11066) -> Result<B256, ReactiveEngineError> {
11067    let requires_payload_commitment = batch.records.iter().any(|record| {
11068        matches!(
11069            &record.input,
11070            ReactiveInput::BlockHeader(_)
11071                | ReactiveInput::FullBlock(_)
11072                | ReactiveInput::PendingTx(_)
11073        )
11074    });
11075    if requires_payload_commitment && batch.payload_commitment.is_none() {
11076        return Err(ReactiveEngineError::MissingPayloadCommitment);
11077    }
11078    let records = batch
11079        .records
11080        .iter()
11081        .enumerate()
11082        .map(|(index, record)| {
11083            let payload = match &record.input {
11084                ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
11085                    address: log.address(),
11086                    topics: log.topics(),
11087                    data: &log.inner.data.data,
11088                    block_hash: log.block_hash,
11089                    block_number: log.block_number,
11090                    block_timestamp: log.block_timestamp,
11091                    transaction_hash: log.transaction_hash,
11092                    transaction_index: log.transaction_index,
11093                    log_index: log.log_index,
11094                    removed: log.removed,
11095                },
11096                ReactiveInput::BlockHeader(_)
11097                | ReactiveInput::FullBlock(_)
11098                | ReactiveInput::PendingTxHash(_)
11099                | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
11100            };
11101            Ok(DeliveryRecordWitness {
11102                identity: record.validated_identity()?,
11103                context: &record.context,
11104                audience: batch
11105                    .record_audience(index)
11106                    .expect("enumerated record always has an audience"),
11107                scope: batch
11108                    .record_delivery_scope(index)
11109                    .expect("enumerated record always has a delivery scope"),
11110                payload,
11111            })
11112        })
11113        .collect::<Result<Vec<_>, ReactiveError>>()?;
11114    let envelope = DeliveryWitnessEnvelope {
11115        version: DELIVERY_WITNESS_VERSION,
11116        chain_id: batch.chain_id,
11117        records,
11118        chain_controls: &batch.chain_controls,
11119        subscriber_checkpoint: batch
11120            .subscriber_checkpoint
11121            .as_ref()
11122            .map(SubscriberCheckpoint::as_bytes),
11123        payload_commitment: batch
11124            .payload_commitment
11125            .as_ref()
11126            .map(SubscriberPayloadCommitment::digest),
11127    };
11128    let encoded = bincode::DefaultOptions::new()
11129        .with_fixint_encoding()
11130        .serialize(&envelope)
11131        .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
11132    let mut witness = Keccak256::new();
11133    witness.update(DELIVERY_WITNESS_DOMAIN);
11134    witness.update(encoded);
11135    Ok(witness.finalize())
11136}
11137
11138impl<S, N> ReactiveEngine<S, N>
11139where
11140    N: Network,
11141    S: EventSubscriber<N>,
11142{
11143    /// Bind a runtime and subscriber.
11144    pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
11145        Self {
11146            runtime,
11147            subscriber,
11148            pending_acknowledgement: None,
11149            pending_checkpoint: None,
11150            last_checkpoint_block: None,
11151            last_checkpoint_delivery_token: None,
11152            last_checkpoint_delivery_witness: None,
11153            last_subscriber_checkpoint: None,
11154            checkpoint_identity: None,
11155        }
11156    }
11157
11158    /// Split the engine into its runtime and subscriber parts when no commit is
11159    /// pending.
11160    ///
11161    /// A failed delivery acknowledgement or durable checkpoint commit remains
11162    /// live protocol state: dropping it would allow the caller to lose the
11163    /// already-applied report/token pair and poll past an uncommitted batch.
11164    /// In that case this returns the intact engine so the caller can repair the
11165    /// dependency and retry through the normal ingestion method.
11166    ///
11167    /// # Errors
11168    ///
11169    /// Returns the intact boxed engine when an acknowledgement or checkpoint
11170    /// commit is pending.
11171    pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
11172        if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
11173            return Err(Box::new(self));
11174        }
11175        Ok((self.runtime, self.subscriber))
11176    }
11177
11178    fn durable_resume_plan(
11179        &self,
11180        metadata: &DurableCheckpointMetadata,
11181    ) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
11182        if !self.subscriber.capabilities().supports_durable_replay() {
11183            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11184        }
11185        self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
11186        if !self.runtime.is_pristine_for_checkpoint_restore()
11187            || self.pending_acknowledgement.is_some()
11188            || self.pending_checkpoint.is_some()
11189            || self.last_checkpoint_block.is_some()
11190            || self.last_checkpoint_delivery_token.is_some()
11191            || self.last_checkpoint_delivery_witness.is_some()
11192            || self.last_subscriber_checkpoint.is_some()
11193            || self.checkpoint_identity.is_some()
11194        {
11195            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11196        }
11197
11198        let block = BlockRef {
11199            number: metadata.block.number,
11200            hash: metadata.block.hash,
11201            parent_hash: metadata.block.parent_hash,
11202            timestamp: metadata.block.timestamp,
11203        };
11204        let runtime = match metadata.runtime_checkpoint.as_deref() {
11205            Some(bytes) => self
11206                .runtime
11207                .plan_durable_checkpoint_restore(bytes, &block)?,
11208            None => DurableRuntimeRestorePlan {
11209                checkpoint: None,
11210                fallback_history: (self.runtime.config.journal_depth > 0)
11211                    .then_some(block)
11212                    .into_iter()
11213                    .collect(),
11214            },
11215        };
11216        let delivery_token = metadata
11217            .delivery_token
11218            .clone()
11219            .map(SubscriberDeliveryToken::new);
11220        let subscriber_checkpoint = metadata
11221            .subscriber_checkpoint
11222            .clone()
11223            .map(SubscriberCheckpoint::new);
11224        let position = SubscriberResumePosition::new(
11225            metadata.identity.chain_id,
11226            block,
11227            runtime.canonical_history(),
11228            delivery_token,
11229            subscriber_checkpoint,
11230        );
11231        Ok(DurableResumePlan {
11232            runtime,
11233            position,
11234            delivery_witness: metadata.delivery_witness,
11235        })
11236    }
11237
11238    /// Preview the exact subscriber position a durable restore will install.
11239    ///
11240    /// This read-only step exists for durable subscribers that must complete
11241    /// asynchronous source or transport preparation before the engine invokes
11242    /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes
11243    /// and validates the core runtime checkpoint, applies this runtime's
11244    /// configured journal retention to the preview, and returns the same
11245    /// [`SubscriberResumePosition`] that
11246    /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11247    /// will later pass to the subscriber.
11248    ///
11249    /// Call this on the same fresh engine that will perform the restore. After
11250    /// subscriber preparation completes, pass the identical `metadata` to
11251    /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint
11252    /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint))
11253    /// without mutating engine runtime or checkpoint state in between. The
11254    /// checkpoint identity and, for non-finalized state, its canonical block
11255    /// must still be validated by the caller before external preparation.
11256    ///
11257    /// This method does not mutate the runtime, subscriber, or checkpoint
11258    /// bookkeeping.
11259    ///
11260    /// # Errors
11261    ///
11262    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11263    /// durable, its chain identity conflicts with the checkpoint, the engine is
11264    /// not fresh, or the stored runtime checkpoint is malformed, unsupported,
11265    /// or internally inconsistent.
11266    pub fn preview_durable_resume_position(
11267        &self,
11268        metadata: &DurableCheckpointMetadata,
11269    ) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
11270        Ok(self.durable_resume_plan(metadata)?.position)
11271    }
11272
11273    /// Resume delivery bookkeeping and canonical continuity from a cache
11274    /// checkpoint that has already been identity- and hash-validated and
11275    /// restored into [`EvmCache`].
11276    ///
11277    /// Call this on a fresh engine. The anchor has no rollback effects of its
11278    /// own: it represents the state baseline embodied by the checkpoint, while
11279    /// newly ingested blocks are journaled normally above it.
11280    /// The subscriber must advertise [`SubscriberCapability::DurableReplay`];
11281    /// restoring an ephemeral stream would claim a restart guarantee it cannot
11282    /// uphold and is rejected before cache or runtime mutation.
11283    ///
11284    /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)
11285    /// when the cache has not yet been restored: that helper rolls the cache
11286    /// back as well if runtime or subscriber activation fails.
11287    ///
11288    /// # Errors
11289    ///
11290    /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not
11291    /// durable, chain identity conflicts, the runtime is not pristine, stored
11292    /// runtime state is invalid, or the subscriber rejects the restored
11293    /// position. Runtime state is restored on subscriber failure.
11294    pub fn resume_from_durable_checkpoint(
11295        &mut self,
11296        metadata: &DurableCheckpointMetadata,
11297    ) -> Result<(), ReactiveCheckpointRestoreError> {
11298        let plan = self.durable_resume_plan(metadata)?;
11299        let prior_runtime = self.runtime.checkpoint_state();
11300
11301        let DurableResumePlan {
11302            runtime,
11303            position,
11304            delivery_witness,
11305        } = plan;
11306        self.runtime.apply_durable_checkpoint_restore(runtime);
11307        self.runtime.coverage_head = Some(position.coverage_head);
11308        if let Err(error) = self.subscriber.restore_position(&position) {
11309            self.runtime.restore_state(prior_runtime);
11310            return Err(ReactiveCheckpointRestoreError::Subscriber(error));
11311        }
11312        if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
11313            self.runtime.restore_state(prior_runtime);
11314            return Err(error);
11315        }
11316        self.last_checkpoint_block = Some(metadata.block.clone());
11317        self.last_checkpoint_delivery_token = position.delivery_token;
11318        self.last_checkpoint_delivery_witness = delivery_witness;
11319        self.last_subscriber_checkpoint = position.subscriber_checkpoint;
11320        self.checkpoint_identity = Some(metadata.identity.clone());
11321        Ok(())
11322    }
11323
11324    /// Atomically restore cache, runtime, and subscriber position from one
11325    /// validated durable checkpoint.
11326    ///
11327    /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical
11328    /// block against an authoritative RPC source before calling this method when
11329    /// the block is not finalized. Identity, cache-chain, runtime-state, and
11330    /// subscriber failures leave the cache and engine runtime unchanged. The
11331    /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract.
11332    /// It must advertise [`SubscriberCapability::DurableReplay`].
11333    ///
11334    /// # Errors
11335    ///
11336    /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity,
11337    /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or
11338    /// position-restore failures. Cache and runtime state remain unchanged.
11339    pub fn restore_durable_checkpoint(
11340        &mut self,
11341        cache: &mut EvmCache,
11342        loaded: LoadedDurableCheckpoint,
11343        expected: &DurableCheckpointIdentity,
11344    ) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
11345        if !self.subscriber.capabilities().supports_durable_replay() {
11346            return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
11347        }
11348        self.ensure_subscriber_restore_chain(expected.chain_id)?;
11349        if !self.runtime.is_pristine_for_checkpoint_restore()
11350            || self.pending_acknowledgement.is_some()
11351            || self.pending_checkpoint.is_some()
11352            || self.last_checkpoint_block.is_some()
11353            || self.last_checkpoint_delivery_token.is_some()
11354            || self.last_checkpoint_delivery_witness.is_some()
11355            || self.last_subscriber_checkpoint.is_some()
11356            || self.checkpoint_identity.is_some()
11357        {
11358            return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
11359        }
11360
11361        let prior_cache = EvmCacheStateSnapshot::capture(cache);
11362        let metadata = loaded.restore_into(cache, expected)?;
11363        if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
11364            prior_cache.restore(cache);
11365            return Err(error);
11366        }
11367        Ok(metadata)
11368    }
11369
11370    /// Borrow the runtime.
11371    pub fn runtime(&self) -> &ReactiveRuntime<N> {
11372        &self.runtime
11373    }
11374
11375    /// Mutably borrow the runtime.
11376    pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
11377        &mut self.runtime
11378    }
11379
11380    /// Borrow the subscriber.
11381    pub fn subscriber(&self) -> &S {
11382        &self.subscriber
11383    }
11384
11385    /// Mutably borrow the subscriber.
11386    pub fn subscriber_mut(&mut self) -> &mut S {
11387        &mut self.subscriber
11388    }
11389
11390    /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical
11391    /// cold-start baseline.
11392    ///
11393    /// The cache must use the exact canonical hash selector and block-number
11394    /// context named by `baseline`; when the baseline includes a timestamp, the
11395    /// cache timestamp must match too. Cache, baseline, and any already-resolved
11396    /// subscriber identity must name the same chain. No delivery or checkpoint
11397    /// commit may be pending. After this succeeds, call
11398    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill)
11399    /// before polling: it exact-replaces subscriber owners and begins event
11400    /// catch-up at `C + 1`.
11401    ///
11402    /// # Errors
11403    ///
11404    /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime
11405    /// is active or already has a conflicting baseline, cache/subscriber chain
11406    /// identity differs, or the cache is not pinned to the exact baseline.
11407    pub fn adopt_canonical_baseline(
11408        &mut self,
11409        cache: &EvmCache,
11410        baseline: ReactiveCanonicalBaseline,
11411    ) -> Result<(), ReactiveEngineError> {
11412        if self.pending_acknowledgement.is_some()
11413            || self.pending_checkpoint.is_some()
11414            || self.last_checkpoint_block.is_some()
11415            || self.last_checkpoint_delivery_token.is_some()
11416            || self.last_checkpoint_delivery_witness.is_some()
11417            || self.last_subscriber_checkpoint.is_some()
11418            || self.checkpoint_identity.is_some()
11419        {
11420            return Err(ReactiveBaselineError::ActiveRuntime.into());
11421        }
11422        // Establish deterministic lifecycle/idempotency semantics before
11423        // consulting mutable cache context. A conflicting repeat is a runtime
11424        // baseline conflict even if the caller also repointed the cache.
11425        self.runtime
11426            .validate_canonical_baseline_adoption(baseline.block)?;
11427        if baseline.chain_id != cache.chain_id() {
11428            return Err(ReactiveBaselineError::CacheChainMismatch {
11429                baseline_chain_id: baseline.chain_id,
11430                cache_chain_id: cache.chain_id(),
11431            }
11432            .into());
11433        }
11434        self.ensure_subscriber_chain(cache)?;
11435        let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
11436        let context_matches = cache.block_number() == Some(baseline.block.number)
11437            && baseline
11438                .block
11439                .timestamp
11440                .is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
11441        if cache.block() != exact_selector || !context_matches {
11442            return Err(ReactiveBaselineError::CacheBlockMismatch {
11443                number: baseline.block.number,
11444                hash: baseline.block.hash,
11445            }
11446            .into());
11447        }
11448        self.runtime.adopt_canonical_baseline(baseline.block)?;
11449        Ok(())
11450    }
11451
11452    /// Poll the subscriber for the next batch without ingesting it.
11453    ///
11454    /// This low-level escape hatch is unavailable while the engine owes an
11455    /// acknowledgement or checkpoint commit. Callers that use it must return
11456    /// any subscriber-owned delivery metadata through a combined
11457    /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately
11458    /// rejects that metadata so it cannot be discarded accidentally.
11459    ///
11460    /// # Errors
11461    ///
11462    /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit
11463    /// is pending or subscriber and cache chain identities conflict.
11464    pub fn next_batch(
11465        &mut self,
11466        cache: &EvmCache,
11467    ) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
11468        if self.pending_checkpoint.is_some() {
11469            return Err(ReactiveEngineError::PendingCheckpointCommit);
11470        }
11471        if self.pending_acknowledgement.is_some() {
11472            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11473        }
11474        self.ensure_subscriber_chain(cache)?;
11475        Ok(self.subscriber.next_batch())
11476    }
11477
11478    /// Ingest one already-polled batch through the runtime (direct effects
11479    /// only; surfaced resync requests are reported, not executed).
11480    ///
11481    /// # Errors
11482    ///
11483    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11484    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11485    /// runtime ingestion fails.
11486    pub fn ingest_batch(
11487        &mut self,
11488        cache: &mut EvmCache,
11489        batch: ReactiveInputBatch<N>,
11490    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11491        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11492        Ok(self.runtime.ingest_batch(cache, batch)?)
11493    }
11494
11495    /// Ingest one already-polled batch and execute the storage/account resyncs
11496    /// it surfaces, exactly like
11497    /// [`ReactiveRuntime::ingest_batch_with_resync`].
11498    ///
11499    /// # Errors
11500    ///
11501    /// Returns [`ReactiveEngineError`] when commit state is pending, the batch
11502    /// carries subscriber-owned commit metadata, chain identity conflicts, or
11503    /// runtime ingestion fails.
11504    pub fn ingest_batch_with_resync(
11505        &mut self,
11506        cache: &mut EvmCache,
11507        batch: ReactiveInputBatch<N>,
11508    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11509        self.ensure_raw_ingest_is_safe(cache, &batch)?;
11510        Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
11511    }
11512
11513    fn ensure_raw_ingest_is_safe(
11514        &self,
11515        cache: &EvmCache,
11516        batch: &ReactiveInputBatch<N>,
11517    ) -> Result<(), ReactiveEngineError> {
11518        if self.pending_checkpoint.is_some() {
11519            return Err(ReactiveEngineError::PendingCheckpointCommit);
11520        }
11521        if self.pending_acknowledgement.is_some() {
11522            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11523        }
11524        if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
11525            return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
11526        }
11527        self.ensure_subscriber_chain(cache)?;
11528        Ok(())
11529    }
11530
11531    fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
11532        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11533            && subscriber_chain_id != cache.chain_id()
11534        {
11535            return Err(ReactiveEngineError::SubscriberChainMismatch {
11536                subscriber_chain_id,
11537                cache_chain_id: cache.chain_id(),
11538            });
11539        }
11540        Ok(())
11541    }
11542
11543    fn ensure_subscriber_restore_chain(
11544        &self,
11545        checkpoint_chain_id: u64,
11546    ) -> Result<(), ReactiveCheckpointRestoreError> {
11547        if let Some(subscriber_chain_id) = self.subscriber.chain_id()
11548            && subscriber_chain_id != checkpoint_chain_id
11549        {
11550            return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
11551                subscriber_chain_id,
11552                checkpoint_chain_id,
11553            });
11554        }
11555        Ok(())
11556    }
11557
11558    /// Poll the subscriber once and ingest the returned batch when present
11559    /// (direct effects only).
11560    ///
11561    /// # Errors
11562    ///
11563    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11564    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11565    /// delivery-acknowledgement failure. A failed acknowledgement remains
11566    /// pending and is retried before polling again.
11567    pub async fn next_ingest(
11568        &mut self,
11569        cache: &mut EvmCache,
11570    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11571        self.ensure_subscriber_chain(cache)?;
11572        if self.pending_checkpoint.is_some() {
11573            return Err(ReactiveEngineError::PendingCheckpointCommit);
11574        }
11575        if self.pending_acknowledgement.is_some() {
11576            return self.commit_pending_acknowledgement().await.map(Some);
11577        }
11578        let batch = self.subscriber.next_batch().await?;
11579        self.ensure_subscriber_chain(cache)?;
11580        let Some(mut batch) = batch else {
11581            return Ok(None);
11582        };
11583        let delivery_token = batch.take_delivery_token();
11584        let report = self.runtime.ingest_batch(cache, batch)?;
11585        self.stage_or_return_acknowledgement(delivery_token, report)
11586            .await
11587    }
11588
11589    /// Poll the subscriber once and ingest the returned batch with resync
11590    /// execution — the loop shape for consumers that rely on coverage-gap
11591    /// repair (root-gate resyncs, handler-requested re-reads).
11592    ///
11593    /// # Errors
11594    ///
11595    /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch,
11596    /// pending checkpoint state, subscriber polling, runtime ingestion, or
11597    /// delivery-acknowledgement failure. A failed acknowledgement remains
11598    /// pending and is retried before polling again.
11599    pub async fn next_ingest_with_resync(
11600        &mut self,
11601        cache: &mut EvmCache,
11602    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11603        self.ensure_subscriber_chain(cache)?;
11604        if self.pending_checkpoint.is_some() {
11605            return Err(ReactiveEngineError::PendingCheckpointCommit);
11606        }
11607        if self.pending_acknowledgement.is_some() {
11608            return self.commit_pending_acknowledgement().await.map(Some);
11609        }
11610        let batch = self.subscriber.next_batch().await?;
11611        self.ensure_subscriber_chain(cache)?;
11612        let Some(mut batch) = batch else {
11613            return Ok(None);
11614        };
11615        let delivery_token = batch.take_delivery_token();
11616        let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
11617        self.stage_or_return_acknowledgement(delivery_token, report)
11618            .await
11619    }
11620
11621    /// Poll, ingest, atomically checkpoint, then acknowledge one batch.
11622    ///
11623    /// The ordering is strict: subscriber acknowledgement is never attempted
11624    /// until the complete cache checkpoint is synced. If checkpointing or
11625    /// acknowledgement fails, the in-memory pending commit is retried before
11626    /// any later batch is polled, so a transient disk failure cannot cause the
11627    /// already-applied batch to execute twice in the same process. Across a
11628    /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint)
11629    /// uses the stored delivery token and delivery witness to recognize and
11630    /// acknowledge an identical replay without re-ingestion. Reusing a token
11631    /// for different input or cursor state fails closed. Mutating the cache while
11632    /// a commit is pending also fails closed rather than binding newer state to
11633    /// older delivery metadata. Any explicit, implicit, or removed-log reorg
11634    /// that cannot be proven from the retained effect journal is rejected before
11635    /// mutation/save/ACK; configure
11636    /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon.
11637    /// Hooks are dispatched only after checkpoint staging
11638    /// succeeds, but remain in-process observers rather than a durable outbox;
11639    /// see [`ReactiveHook`]. The subscriber must advertise
11640    /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are
11641    /// rejected before polling.
11642    ///
11643    /// # Errors
11644    ///
11645    /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay,
11646    /// identities or replay witnesses conflict, a checkpoint/ACK is already in
11647    /// an incompatible state, polling or ingestion fails, complete rollback
11648    /// proof is unavailable, the cache changes after staging, persistence
11649    /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is
11650    /// retained for retry before another poll.
11651    pub async fn next_ingest_checkpointed(
11652        &mut self,
11653        cache: &mut EvmCache,
11654        store: &DurableCheckpointStore,
11655        identity: &DurableCheckpointIdentity,
11656    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11657        if !self.subscriber.capabilities().supports_durable_replay() {
11658            return Err(ReactiveEngineError::SubscriberNotDurable);
11659        }
11660        self.ensure_subscriber_chain(cache)?;
11661        if self.pending_acknowledgement.is_some() {
11662            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11663        }
11664        self.ensure_checkpoint_identity(cache, identity)?;
11665        if self.pending_checkpoint.is_some() {
11666            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11667        }
11668
11669        let batch = self.subscriber.next_batch().await?;
11670        self.ensure_subscriber_chain(cache)?;
11671        let Some(mut batch) = batch else {
11672            return Ok(None);
11673        };
11674        if batch_preconfirmation(&batch)?.is_some() {
11675            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11676        }
11677        self.runtime.discard_preconfirmed_branch(cache);
11678        let delivery_witness = batch
11679            .delivery_token()
11680            .map(|_| durable_delivery_witness(&batch))
11681            .transpose()?;
11682        let delivery_token = batch.take_delivery_token();
11683        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11684        if let (Some(replay_token), Some(committed_token)) = (
11685            delivery_token.as_ref(),
11686            self.last_checkpoint_delivery_token.as_ref(),
11687        ) && replay_token == committed_token
11688        {
11689            let committed_witness = self
11690                .last_checkpoint_delivery_witness
11691                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11692            if delivery_witness != Some(committed_witness) {
11693                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11694            }
11695            self.subscriber
11696                .acknowledge_delivery(replay_token.clone())
11697                .await
11698                .map_err(ReactiveEngineError::Acknowledgement)?;
11699            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11700        }
11701
11702        self.ensure_checkpointable_reorgs(&batch)?;
11703
11704        let incoming_block = latest_canonical_batch_block(&batch);
11705        let cache_state = EvmCacheStateSnapshot::capture(cache);
11706        let runtime_state = self.runtime.checkpoint_state();
11707        let report = match self.runtime.ingest_batch_direct(cache, batch) {
11708            Ok(report) => report,
11709            Err(error) => {
11710                cache_state.restore(cache);
11711                self.runtime.restore_transaction_state(runtime_state);
11712                return Err(error.into());
11713            }
11714        };
11715        let reports = report.reports.clone();
11716        let stage = CheckpointStage {
11717            incoming_block,
11718            delivery_token,
11719            delivery_witness,
11720            subscriber_checkpoint,
11721            staged_generation: cache.snapshot_generation(),
11722            report,
11723        };
11724        if let Err(error) = self.stage_checkpoint(identity, stage) {
11725            cache_state.restore(cache);
11726            self.runtime.restore_transaction_state(runtime_state);
11727            return Err(error);
11728        }
11729        self.runtime.dispatch_reports(&reports);
11730        self.commit_pending_checkpoint(cache, store).await.map(Some)
11731    }
11732
11733    /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync).
11734    /// Requires [`SubscriberCapability::DurableReplay`] and rejects an
11735    /// ephemeral subscriber before polling.
11736    ///
11737    /// # Errors
11738    ///
11739    /// Returns [`ReactiveEngineError`] for the same durability, identity,
11740    /// rollback-proof, replay-witness, polling, ingestion, persistence,
11741    /// mutation-fence, and acknowledgement failures as
11742    /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed).
11743    pub async fn next_ingest_with_resync_checkpointed(
11744        &mut self,
11745        cache: &mut EvmCache,
11746        store: &DurableCheckpointStore,
11747        identity: &DurableCheckpointIdentity,
11748    ) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
11749        if !self.subscriber.capabilities().supports_durable_replay() {
11750            return Err(ReactiveEngineError::SubscriberNotDurable);
11751        }
11752        self.ensure_subscriber_chain(cache)?;
11753        if self.pending_acknowledgement.is_some() {
11754            return Err(ReactiveEngineError::PendingAcknowledgementCommit);
11755        }
11756        self.ensure_checkpoint_identity(cache, identity)?;
11757        if self.pending_checkpoint.is_some() {
11758            return self.commit_pending_checkpoint(cache, store).await.map(Some);
11759        }
11760
11761        let batch = self.subscriber.next_batch().await?;
11762        self.ensure_subscriber_chain(cache)?;
11763        let Some(mut batch) = batch else {
11764            return Ok(None);
11765        };
11766        if batch_preconfirmation(&batch)?.is_some() {
11767            return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
11768        }
11769        self.runtime.discard_preconfirmed_branch(cache);
11770        let delivery_witness = batch
11771            .delivery_token()
11772            .map(|_| durable_delivery_witness(&batch))
11773            .transpose()?;
11774        let delivery_token = batch.take_delivery_token();
11775        let subscriber_checkpoint = batch.take_subscriber_checkpoint();
11776        if let (Some(replay_token), Some(committed_token)) = (
11777            delivery_token.as_ref(),
11778            self.last_checkpoint_delivery_token.as_ref(),
11779        ) && replay_token == committed_token
11780        {
11781            let committed_witness = self
11782                .last_checkpoint_delivery_witness
11783                .ok_or(ReactiveEngineError::MissingReplayWitness)?;
11784            if delivery_witness != Some(committed_witness) {
11785                return Err(ReactiveEngineError::ReplayDeliveryMismatch);
11786            }
11787            self.subscriber
11788                .acknowledge_delivery(replay_token.clone())
11789                .await
11790                .map_err(ReactiveEngineError::Acknowledgement)?;
11791            return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
11792        }
11793
11794        self.ensure_checkpointable_reorgs(&batch)?;
11795
11796        let incoming_block = latest_canonical_batch_block(&batch);
11797        let cache_state = EvmCacheStateSnapshot::capture(cache);
11798        let runtime_state = self.runtime.checkpoint_state();
11799        let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
11800            Ok(report) => report,
11801            Err(error) => {
11802                cache_state.restore(cache);
11803                self.runtime.restore_transaction_state(runtime_state);
11804                return Err(error.into());
11805            }
11806        };
11807        let reports = report.reports.clone();
11808        let stage = CheckpointStage {
11809            incoming_block,
11810            delivery_token,
11811            delivery_witness,
11812            subscriber_checkpoint,
11813            staged_generation: cache.snapshot_generation(),
11814            report,
11815        };
11816        if let Err(error) = self.stage_checkpoint(identity, stage) {
11817            cache_state.restore(cache);
11818            self.runtime.restore_transaction_state(runtime_state);
11819            return Err(error);
11820        }
11821        self.runtime.dispatch_reports(&reports);
11822        self.commit_pending_checkpoint(cache, store).await.map(Some)
11823    }
11824
11825    fn stage_checkpoint(
11826        &mut self,
11827        identity: &DurableCheckpointIdentity,
11828        stage: CheckpointStage<N>,
11829    ) -> Result<(), ReactiveEngineError> {
11830        let CheckpointStage {
11831            incoming_block,
11832            delivery_token,
11833            delivery_witness,
11834            subscriber_checkpoint,
11835            staged_generation,
11836            report,
11837        } = stage;
11838        if delivery_token.is_some() != delivery_witness.is_some() {
11839            return Err(ReactiveEngineError::DeliveryWitness(
11840                "delivery token and witness must be staged together".into(),
11841            ));
11842        }
11843        let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
11844        let block = self
11845            .runtime
11846            .last_canonical_block()
11847            .map(|block| DurableCheckpointBlock {
11848                number: block.number,
11849                hash: block.hash,
11850                parent_hash: block.parent_hash,
11851                timestamp: block.timestamp,
11852            })
11853            .or(incoming_block)
11854            .or_else(|| self.last_checkpoint_block.clone())
11855            .ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
11856        let metadata = DurableCheckpointMetadata {
11857            identity: identity.clone(),
11858            block,
11859            delivery_token: delivery_token
11860                .as_ref()
11861                .or(self.last_checkpoint_delivery_token.as_ref())
11862                .map(|token| token.as_bytes().to_vec()),
11863            delivery_witness: if delivery_token.is_some() {
11864                delivery_witness
11865            } else {
11866                self.last_checkpoint_delivery_witness
11867            },
11868            subscriber_checkpoint: subscriber_checkpoint
11869                .as_ref()
11870                .or(self.last_subscriber_checkpoint.as_ref())
11871                .map(|checkpoint| checkpoint.as_bytes().to_vec()),
11872            runtime_checkpoint: Some(runtime_checkpoint),
11873        };
11874        self.pending_checkpoint = Some(PendingCheckpoint {
11875            metadata,
11876            delivery_token,
11877            report,
11878            saved_to: None,
11879            staged_generation,
11880        });
11881        Ok(())
11882    }
11883
11884    fn ensure_checkpointable_reorgs(
11885        &self,
11886        batch: &ReactiveInputBatch<N>,
11887    ) -> Result<(), ReactiveEngineError> {
11888        let state = CanonicalSequenceState::new(
11889            self.runtime
11890                .journal
11891                .iter()
11892                .map(|entry| entry.block)
11893                .collect(),
11894            self.runtime.coverage_head,
11895            self.runtime.safe_head,
11896            self.runtime.finalized_head,
11897        );
11898        match validate_canonical_sequence_internal(
11899            &state,
11900            batch,
11901            CanonicalSequenceValidationPolicy::RequireCompleteRollback,
11902        ) {
11903            Ok(_) => Ok(()),
11904            Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
11905            Err(CanonicalSequenceError::IncompleteRollback {
11906                common_ancestor,
11907                oldest_retained,
11908                ..
11909            }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
11910                common_ancestor,
11911                oldest_journaled: oldest_retained,
11912                journal_depth: self.runtime.config.journal_depth,
11913            }),
11914        }
11915    }
11916
11917    async fn stage_or_return_acknowledgement(
11918        &mut self,
11919        delivery_token: Option<SubscriberDeliveryToken>,
11920        report: ReactiveBatchReport<N>,
11921    ) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
11922        let Some(token) = delivery_token else {
11923            return Ok(Some(report));
11924        };
11925        self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
11926        self.commit_pending_acknowledgement().await.map(Some)
11927    }
11928
11929    async fn commit_pending_acknowledgement(
11930        &mut self,
11931    ) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
11932        let token = self
11933            .pending_acknowledgement
11934            .as_ref()
11935            .expect("caller checked pending acknowledgement")
11936            .token
11937            .clone();
11938        self.subscriber
11939            .acknowledge_delivery(token)
11940            .await
11941            .map_err(ReactiveEngineError::Acknowledgement)?;
11942        Ok(self
11943            .pending_acknowledgement
11944            .take()
11945            .expect("pending acknowledgement remains until commit")
11946            .report)
11947    }
11948
11949    async fn commit_pending_checkpoint(
11950        &mut self,
11951        cache: &EvmCache,
11952        store: &DurableCheckpointStore,
11953    ) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
11954        let pending = self
11955            .pending_checkpoint
11956            .as_mut()
11957            .expect("caller checked pending checkpoint");
11958        let cache_generation = cache.snapshot_generation();
11959        if cache_generation != pending.staged_generation {
11960            return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
11961                staged_generation: pending.staged_generation,
11962                current_generation: cache_generation,
11963            });
11964        }
11965        if pending.saved_to.as_deref() != Some(store.path()) {
11966            store
11967                .save_async(cache, pending.metadata.clone())
11968                .await
11969                .map_err(ReactiveEngineError::Checkpoint)?;
11970            pending.saved_to = Some(store.path().to_path_buf());
11971        }
11972        if let Some(token) = pending.delivery_token.clone() {
11973            self.subscriber
11974                .acknowledge_delivery(token)
11975                .await
11976                .map_err(ReactiveEngineError::Acknowledgement)?;
11977        }
11978
11979        let pending = self
11980            .pending_checkpoint
11981            .take()
11982            .expect("pending checkpoint remains until commit");
11983        self.last_checkpoint_block = Some(pending.metadata.block);
11984        self.checkpoint_identity = Some(pending.metadata.identity);
11985        self.last_checkpoint_delivery_token = pending
11986            .metadata
11987            .delivery_token
11988            .map(SubscriberDeliveryToken::new);
11989        self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
11990        self.last_subscriber_checkpoint = pending
11991            .metadata
11992            .subscriber_checkpoint
11993            .map(SubscriberCheckpoint::new);
11994        Ok(CheckpointedIngest::Applied(pending.report))
11995    }
11996
11997    fn ensure_checkpoint_identity(
11998        &self,
11999        cache: &EvmCache,
12000        identity: &DurableCheckpointIdentity,
12001    ) -> Result<(), ReactiveEngineError> {
12002        if identity.chain_id != cache.chain_id() {
12003            return Err(ReactiveEngineError::Checkpoint(
12004                DurableCheckpointError::CacheChainMismatch {
12005                    cache_chain_id: cache.chain_id(),
12006                    checkpoint_chain_id: identity.chain_id,
12007                },
12008            ));
12009        }
12010        if let Some(actual) = self.checkpoint_identity.as_ref()
12011            && actual != identity
12012        {
12013            return Err(ReactiveEngineError::Checkpoint(
12014                DurableCheckpointError::IdentityMismatch {
12015                    expected: identity.clone(),
12016                    actual: actual.clone(),
12017                },
12018            ));
12019        }
12020        if let Some(pending) = self.pending_checkpoint.as_ref()
12021            && &pending.metadata.identity != identity
12022        {
12023            return Err(ReactiveEngineError::Checkpoint(
12024                DurableCheckpointError::IdentityMismatch {
12025                    expected: identity.clone(),
12026                    actual: pending.metadata.identity.clone(),
12027                },
12028            ));
12029        }
12030        Ok(())
12031    }
12032}
12033
12034fn latest_canonical_batch_block<N: Network>(
12035    batch: &ReactiveInputBatch<N>,
12036) -> Option<DurableCheckpointBlock> {
12037    let record_block = batch
12038        .records()
12039        .iter()
12040        .enumerate()
12041        .filter(|(index, _)| {
12042            batch
12043                .record_delivery_scope(*index)
12044                .is_some_and(DeliveryScope::advances_canonical_state)
12045        })
12046        .filter_map(|(_, record)| canonical_record_block(record))
12047        .max_by_key(|block| block.number)
12048        .cloned();
12049    let control_block = batch
12050        .chain_controls()
12051        .iter()
12052        .filter_map(|control| match control {
12053            ChainControl::Reorg {
12054                common_ancestor, ..
12055            } => Some(common_ancestor),
12056            ChainControl::Barrier {
12057                block: Some(block), ..
12058            }
12059            | ChainControl::CanonicalProgress(block) => Some(block),
12060            ChainControl::Safe(_)
12061            | ChainControl::Finalized(_)
12062            | ChainControl::Barrier { block: None, .. } => None,
12063        })
12064        .max_by_key(|block| block.number)
12065        .cloned();
12066
12067    record_block
12068        .into_iter()
12069        .chain(control_block)
12070        .max_by_key(|block| block.number)
12071        .map(|block| DurableCheckpointBlock {
12072            number: block.number,
12073            hash: block.hash,
12074            parent_hash: block.parent_hash,
12075            timestamp: block.timestamp,
12076        })
12077}
12078
12079impl<S, N> ReactiveEngine<S, N>
12080where
12081    N: Network,
12082    S: InterestOwnerSubscriber<N>,
12083{
12084    /// Register a handler with both the runtime and subscriber, backfilling its
12085    /// log interests from the runtime's last canonical block.
12086    ///
12087    /// This is the continuity-safe default for mid-lifecycle registration. The
12088    /// subscriber adopts the live desired state first, delivers the new owner's
12089    /// matching records at retained block `C` as owner catch-up, then delivers
12090    /// `C + 1` through activation as global canonical catch-up over the complete
12091    /// handler union. No discovery gap opens, and every effect after `C` enters
12092    /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet
12093    /// (fresh start, or `journal_depth` 0) registration is live-only, matching
12094    /// pre-ingestion bootstrap. Use
12095    /// [`register_handler_with_backfill`](Self::register_handler_with_backfill)
12096    /// for an explicit replay of one retained block or
12097    /// [`register_handler_live_only`](Self::register_handler_live_only) to opt
12098    /// out of backfill entirely.
12099    ///
12100    /// Subscriber registration commits before runtime routing is installed. If
12101    /// the subscriber operation fails or is cancelled, the runtime remains
12102    /// unchanged.
12103    ///
12104    /// # Errors
12105    ///
12106    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12107    /// registered or the subscriber rejects/does not support the required
12108    /// owner update or coordinated catch-up.
12109    pub async fn register_handler(
12110        &mut self,
12111        handler: Arc<dyn ReactiveHandler<N>>,
12112    ) -> Result<(), ReactiveEngineRegisterError> {
12113        let backfill = self
12114            .runtime
12115            .last_canonical_block()
12116            .filter(|retained| {
12117                self.runtime.journal.iter().any(|entry| {
12118                    optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
12119                })
12120            })
12121            .map(HandlerRegistrationCatchup::CoordinatedCanonical)
12122            .unwrap_or(HandlerRegistrationCatchup::LiveOnly);
12123        self.register_handler_inner(handler, backfill).await
12124    }
12125
12126    /// Register a handler and replay its matching logs at one exact retained
12127    /// canonical block.
12128    ///
12129    /// Owner-only effects are appended to that block's existing rollback
12130    /// journal entry. Consequently this method accepts only a bounded
12131    /// [`SubscriberBackfill`] whose start, end, and hash-certified retained
12132    /// anchor all identify the same journaled block. Wider/deeper recovery must
12133    /// use ordinary global canonical ingestion (for example startup catch-up),
12134    /// where every handler sees the records and the runtime advances coverage.
12135    ///
12136    /// If subscriber registration fails or is cancelled, the runtime remains
12137    /// unchanged.
12138    ///
12139    /// # Errors
12140    ///
12141    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12142    /// registered, the requested backfill is not exactly one hash-certified
12143    /// retained journal block, or the subscriber update fails.
12144    pub async fn register_handler_with_backfill(
12145        &mut self,
12146        handler: Arc<dyn ReactiveHandler<N>>,
12147        backfill: SubscriberBackfill,
12148    ) -> Result<(), ReactiveEngineRegisterError> {
12149        self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
12150            .await
12151    }
12152
12153    /// Register a handler without any log backfill — only logs delivered after
12154    /// its live subscription starts are routed to it.
12155    ///
12156    /// If subscriber registration fails or is cancelled, the runtime remains
12157    /// unchanged.
12158    ///
12159    /// # Errors
12160    ///
12161    /// Returns [`ReactiveEngineRegisterError`] when the handler id is already
12162    /// registered or the subscriber cannot commit the owner update.
12163    pub async fn register_handler_live_only(
12164        &mut self,
12165        handler: Arc<dyn ReactiveHandler<N>>,
12166    ) -> Result<(), ReactiveEngineRegisterError> {
12167        self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
12168            .await
12169    }
12170
12171    async fn register_handler_inner(
12172        &mut self,
12173        handler: Arc<dyn ReactiveHandler<N>>,
12174        catchup: HandlerRegistrationCatchup,
12175    ) -> Result<(), ReactiveEngineRegisterError> {
12176        let id = handler.id();
12177        if self.runtime.contains_handler(&id) {
12178            return Err(RegisterError::DuplicateHandler(id).into());
12179        }
12180        let interests = handler.interests();
12181
12182        if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
12183            let retained_anchor = backfill.retained_anchor().copied();
12184            let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
12185                backfill.start_block() == anchor.number
12186                    && backfill.end_block() == Some(anchor.number)
12187                    && self.runtime.journal.iter().any(|entry| {
12188                        optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
12189                    })
12190            });
12191            if !is_exact_retained_block {
12192                return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
12193                    start_block: backfill.start_block(),
12194                    end_block: backfill.end_block(),
12195                    retained_anchor,
12196                });
12197            }
12198        }
12199
12200        let subscribed = match catchup {
12201            HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
12202                self.subscriber
12203                    .add_interest_owner_with_backfill(id.clone(), &interests, backfill)
12204                    .await
12205            }
12206            HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
12207                self.subscriber
12208                    .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
12209                    .await
12210            }
12211            HandlerRegistrationCatchup::LiveOnly => {
12212                self.subscriber
12213                    .add_interest_owner(id.clone(), &interests)
12214                    .await
12215            }
12216        };
12217        if let Err(error) = subscribed {
12218            return Err(error.into());
12219        }
12220
12221        // `&mut self` excludes concurrent registry mutation between the
12222        // duplicate preflight and this commit. Registration is deliberately
12223        // subscriber-first: cancelling the awaited operation cannot leave a
12224        // runtime handler active without committed subscriber interests.
12225        self.runtime
12226            .registry
12227            .insert_handler_prepared(id, handler, interests);
12228        Ok(())
12229    }
12230
12231    /// Register every handler currently in the runtime registry as a subscriber
12232    /// interest owner.
12233    ///
12234    /// This is the no-history bootstrap path for a fresh runtime/subscriber pair
12235    /// before ingestion starts, or for reattaching an already-aligned durable
12236    /// subscriber whose exact owner state was restored independently. Each
12237    /// handler becomes its own owner through one exact bulk replacement;
12238    /// crash-stale owners and unowned/base interests are removed.
12239    ///
12240    /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or
12241    /// potentially stale subscriber after the runtime has processed canonical
12242    /// state: use
12243    /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill),
12244    /// which exact-replaces the owner set and closes continuity from the
12245    /// restored runtime position.
12246    ///
12247    /// The complete exact set commits through one subscriber operation; an
12248    /// error or cancellation leaves the previously committed topology
12249    /// authoritative.
12250    ///
12251    /// # Errors
12252    ///
12253    /// Returns [`SubscriberError`] when the subscriber cannot atomically
12254    /// replace the complete owner topology.
12255    pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
12256        let owners = self
12257            .runtime
12258            .handler_ids()
12259            .into_iter()
12260            .map(|id| {
12261                let interests = self
12262                    .runtime
12263                    .handler_interests(&id)
12264                    .map(<[ReactiveInterest<N>]>::to_vec)
12265                    .unwrap_or_default();
12266                (id, interests)
12267            })
12268            .collect();
12269        self.subscriber.replace_interest_owners(owners).await
12270    }
12271
12272    /// Rebuild subscriber owner state from a runtime that already embodies a
12273    /// canonical checkpoint.
12274    ///
12275    /// The runtime registry is authoritative: the subscriber must atomically
12276    /// replace its complete owner set, removing crash-stale owners as well as
12277    /// adding the current ones. Log catch-up is routed globally through normal
12278    /// canonical ingestion and begins strictly at `C + 1`, where
12279    /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored
12280    /// cache already contains every effect through `C`. The exact number/hash
12281    /// identity of `C` remains attached as a retained baseline and must be
12282    /// validated by the subscriber before it exposes post-baseline records.
12283    /// Global routing is essential: startup catch-up effects enter the ordinary
12284    /// canonical journal and can be rolled back if the certified branch later
12285    /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle
12286    /// handler addition.
12287    ///
12288    /// A runtime without a canonical position must use
12289    /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block
12290    /// `u64::MAX` is rejected rather than wrapping or replaying the baseline.
12291    /// The replacement is one subscriber commit boundary: errors and
12292    /// cancellation leave the previous topology authoritative.
12293    ///
12294    /// # Errors
12295    ///
12296    /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline
12297    /// exists or no exclusive successor can be represented, and otherwise
12298    /// propagates subscriber validation, transport, or atomic-commit failures.
12299    pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
12300        let baseline =
12301            self.runtime
12302                .last_canonical_block()
12303                .ok_or(SubscriberError::InvalidConfig(
12304                    "cannot continuity-sync handlers before a canonical runtime position exists",
12305                ))?;
12306        let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
12307        let owners = self
12308            .runtime
12309            .handler_ids()
12310            .into_iter()
12311            .map(|id| {
12312                let interests = self
12313                    .runtime
12314                    .handler_interests(&id)
12315                    .map(<[ReactiveInterest<N>]>::to_vec)
12316                    .unwrap_or_default();
12317                (id, interests)
12318            })
12319            .collect();
12320        self.subscriber
12321            .replace_interest_owners_with_global_backfill(owners, backfill)
12322            .await
12323    }
12324
12325    /// Unregister a handler from both the subscriber and runtime.
12326    ///
12327    /// Subscriber interests are removed first so no new live records are routed
12328    /// to a handler after it has left the runtime registry. Returns the removed
12329    /// handler when the id was registered. If subscriber removal fails or is
12330    /// cancelled, runtime routing remains installed.
12331    ///
12332    /// This is the routing/transport half of dropping an adapter. State the
12333    /// handler accumulated is deliberately left in place; the complete teardown
12334    /// for a pool or adapter that will not return is:
12335    ///
12336    /// ```text
12337    /// engine.unregister_handler(&id).await?;
12338    /// for request_id in handler_request_ids {
12339    ///     // Drop only this handler generation's queued repair work.
12340    ///     engine.runtime_mut().cancel_pending_resync(&request_id);
12341    /// }
12342    /// for address in exclusively_owned_addresses {
12343    ///     // Shared accounts require caller-side owner reference counting.
12344    ///     engine.runtime_mut().untrack_account(address);
12345    /// }
12346    /// // optional: evict cached state via StateUpdate::purge / cache purge APIs
12347    /// ```
12348    ///
12349    /// Health, metrics, the reorg journal, hooks, and freshness stamps are
12350    /// runtime-global and are never touched by handler removal.
12351    ///
12352    /// # Errors
12353    ///
12354    /// Returns [`SubscriberError`] when the subscriber cannot commit owner
12355    /// removal. In that case runtime routing remains installed.
12356    pub async fn unregister_handler(
12357        &mut self,
12358        id: &HandlerId,
12359    ) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
12360        self.subscriber.remove_interest_owner(id).await?;
12361        Ok(self.runtime.unregister_handler(id))
12362    }
12363}
12364
12365type FlashblockReconnectFuture<N> = Pin<
12366    Box<
12367        dyn Future<
12368                Output = (
12369                    SubscriberStreamSource,
12370                    Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
12371                ),
12372            > + Send,
12373    >,
12374>;
12375
12376/// Alloy-backed event subscriber.
12377///
12378/// The default transport slice drives Alloy pubsub subscriptions for logs,
12379/// block headers, and pending transaction hashes. The HTTP polling `watch_*`
12380/// transport remains available behind the opt-in `reactive-polling` feature.
12381/// Pubsub streams reconnect automatically after termination, and log
12382/// subscriptions are backfilled from the last seen block. Owner-scoped log
12383/// additions can request backfill from an explicit block anchor. Full pending
12384/// transaction hydration and full block bodies remain explicit follow-up work.
12385///
12386/// Historical log fetching is deliberately a bounded live-subscriber aid, not
12387/// a high-volume indexer: each filter/window is issued as one complete-range
12388/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects
12389/// an oversized decoded response, but the subscriber does not adaptively split
12390/// block ranges and cannot bypass an RPC provider's result cap. Keep owner
12391/// registration and reconnect windows modest; use an indexing source such as
12392/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up.
12393///
12394/// With no registered interests, [`EventSubscriber::next_batch`] returns
12395/// `Ok(None)`.
12396pub struct AlloySubscriber<P, N: Network = Ethereum> {
12397    provider: P,
12398    /// Stable identity of an application-managed standardized Flashblock
12399    /// update source. The application owns its transport and lifecycle.
12400    #[cfg(feature = "raw-flashblocks-json")]
12401    external_flashblocks_provider: Option<ProviderRef>,
12402    /// Receiving half of the optional bounded application-to-subscriber queue.
12403    #[cfg(feature = "raw-flashblocks-json")]
12404    external_flashblock_updates:
12405        Option<tokio::sync::mpsc::Receiver<raw_json_flashblocks::QueuedFlashblockUpdate>>,
12406    /// Whether an external update queue was opened for this subscriber.
12407    #[cfg(feature = "raw-flashblocks-json")]
12408    external_flashblock_update_channel_opened: bool,
12409    /// Highest external generation rejected by subscriber-level validation.
12410    #[cfg(feature = "raw-flashblocks-json")]
12411    rejected_external_flashblock_generation: Option<u64>,
12412    /// Last accepted externally standardized snapshot, retained so callers
12413    /// cannot bypass indexed-payload continuity enforced by the raw adapter.
12414    #[cfg(feature = "raw-flashblocks-json")]
12415    last_external_flashblock_snapshot: Option<FlashblockSnapshot>,
12416    /// Optional request/response half of the same configured provider lease.
12417    /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC
12418    /// does not expose the provider's pending-state surface.
12419    flashblocks_state_provider: Option<P>,
12420    /// Stable identity for the provider session used by Flashblocks and every
12421    /// follow-up pending-state read.
12422    provider_ref: Option<ProviderRef>,
12423    /// Optional provider dedicated to canonical log-context verification.
12424    /// Keeping this separate prevents a high-volume pubsub connection from
12425    /// starving its own verification requests behind log notifications.
12426    log_verification_provider: Option<P>,
12427    /// Provider chain identity, resolved once before any record can escape.
12428    chain_id: Option<u64>,
12429    mode: SubscriberMode,
12430    config: SubscriberConfig,
12431    base_interests: Vec<ReactiveInterest<N>>,
12432    owned_interests: Vec<OwnedSubscriberInterests<N>>,
12433    next_owner_epoch: u64,
12434    interests: Vec<ReactiveInterest<N>>,
12435    /// Stable source id per distinct provider-facing log filter. Ids key
12436    /// delivery anchors and live `SubscriberEvent`s; entries are retired (and
12437    /// their anchors pruned) when no planned stream references the filter, so
12438    /// long-lived owner churn cannot grow this map unboundedly.
12439    log_source_ids: HashMap<Filter, usize>,
12440    next_log_source_id: usize,
12441    pending_backfills: VecDeque<QueuedSubscriberBackfill>,
12442    /// Successfully connected sources whose subscribe-then-backfill step has
12443    /// not committed yet. Installation happens before the backfill await, so a
12444    /// cancelled reconcile keeps the live stream and retries only the missing
12445    /// historical window.
12446    pending_source_backfills: VecDeque<SubscriberStreamSource>,
12447    /// Set when interest bookkeeping changed since the last successful stream
12448    /// reconcile, so steady-state polling skips the desired-vs-live diff.
12449    sources_dirty: bool,
12450    /// Conservative generation of desired/live stream topology. Successful
12451    /// owner progress is activatable only against the same clean revision.
12452    stream_revision: u64,
12453    state: AlloySubscriberState<N>,
12454    pending_records: VecDeque<SubscriberInputRecord<N>>,
12455    pending_chain_controls: VecDeque<ChainControl>,
12456    /// Owner copies of live records consumed during an in-flight reconcile.
12457    /// These remain hidden from subscriber output until the owning reconcile
12458    /// commits and survive cancellation so subscribe-first adoption cannot
12459    /// lose an event at an await boundary.
12460    pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
12461    /// Sticky fail-closed capacity error. Once an event could not be retained,
12462    /// only a full replacement registration can establish a new baseline.
12463    resource_error: Option<String>,
12464    last_seen_log_blocks: HashMap<usize, u64>,
12465    verified_log_blocks: HashMap<(u64, B256), BlockRef>,
12466    verified_log_block_order: VecDeque<(u64, B256)>,
12467    recent_input_refs: VecDeque<InputRef>,
12468    recent_input_ref_set: HashSet<InputRef>,
12469    recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
12470    recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
12471    recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
12472    recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
12473    base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
12474    base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
12475    unmatched_pending_logs: VecDeque<(usize, Log)>,
12476    latest_preconfirmation: Option<FlashblockRef>,
12477    preconfirmed_seen_logs: HashSet<(B256, u64)>,
12478    /// OP transaction receipts already proven for the active cumulative
12479    /// payload. This avoids re-querying non-matching transactions while still
12480    /// retrying receipts that were temporarily unavailable.
12481    preconfirmed_receipted_transactions: HashSet<B256>,
12482    /// OP receipt hashes that returned `null` at least once for the active
12483    /// payload. Never-attempted hashes are scheduled ahead of this retry set so
12484    /// a lagging provider cache cannot let a few transactions monopolize the
12485    /// bounded request budget.
12486    preconfirmed_unavailable_receipts: HashSet<B256>,
12487    last_certified_canonical_head: Option<BlockRef>,
12488    pending_preconfirmation_invalidation: bool,
12489    pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
12490    pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
12491    flashblocks_rpc_metrics: FlashblocksRpcMetrics,
12492    consecutive_flashblock_poll_failures: usize,
12493    flashblock_rpc_request_times: VecDeque<Instant>,
12494    _network: PhantomData<N>,
12495}
12496
12497struct OwnedSubscriberInterests<N: Network = Ethereum> {
12498    owner: HandlerId,
12499    interests: Vec<ReactiveInterest<N>>,
12500    epoch: Option<SubscriberOwnerEpoch>,
12501    state: SubscriberOwnerState,
12502    baseline: Option<BlockRef>,
12503    progress: Option<SubscriberOwnerProgress>,
12504    progress_stream_revision: Option<u64>,
12505}
12506
12507#[derive(Clone)]
12508struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
12509    epoch: SubscriberOwnerEpoch,
12510    interests: Vec<ReactiveInterest<N>>,
12511    retained: BlockRef,
12512    from_block: u64,
12513}
12514
12515struct SubscriberOwnerCatchup {
12516    logs: Vec<Log>,
12517    certified: BlockRef,
12518}
12519
12520#[derive(Clone, Copy)]
12521struct SubscriberOwnerCatchupOptions {
12522    target_preverified: bool,
12523    max_logs: usize,
12524    max_log_bytes: usize,
12525    max_requests_in_flight: usize,
12526}
12527
12528struct SubscriberOwnerReconcileFilter {
12529    filter: Filter,
12530    from_block: u64,
12531}
12532
12533struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
12534    record: ReactiveInputRecord<N>,
12535    owners: Vec<SubscriberOwnerEpoch>,
12536}
12537
12538const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
12539
12540struct QueuedSubscriberBackfill {
12541    /// `None` means global canonical catch-up; `Some` is compatibility
12542    /// owner-only catch-up for true mid-lifecycle additions.
12543    owner: Option<HandlerId>,
12544    epoch: Option<SubscriberOwnerEpoch>,
12545    /// Complete logical filter set for one certified, globally ordered window.
12546    filters: Vec<Filter>,
12547    backfill: SubscriberBackfill,
12548}
12549
12550/// Best-effort installation of rustls' `ring` crypto provider as the process
12551/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with
12552/// "no process-level CryptoProvider available". Runs at most once and ignores the
12553/// error if a default provider is already installed (the host app may have set
12554/// its own).
12555#[cfg(feature = "reactive-ws")]
12556fn ensure_ring_crypto_provider() {
12557    use std::sync::Once;
12558    static INSTALL: Once = Once::new();
12559    INSTALL.call_once(|| {
12560        let _ = rustls::crypto::ring::default_provider().install_default();
12561    });
12562}
12563
12564impl<P, N: Network> AlloySubscriber<P, N> {
12565    /// Create a new Alloy subscriber.
12566    pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
12567        #[cfg(feature = "reactive-ws")]
12568        ensure_ring_crypto_provider();
12569        Self {
12570            provider,
12571            #[cfg(feature = "raw-flashblocks-json")]
12572            external_flashblocks_provider: None,
12573            #[cfg(feature = "raw-flashblocks-json")]
12574            external_flashblock_updates: None,
12575            #[cfg(feature = "raw-flashblocks-json")]
12576            external_flashblock_update_channel_opened: false,
12577            #[cfg(feature = "raw-flashblocks-json")]
12578            rejected_external_flashblock_generation: None,
12579            #[cfg(feature = "raw-flashblocks-json")]
12580            last_external_flashblock_snapshot: None,
12581            flashblocks_state_provider: None,
12582            provider_ref: None,
12583            log_verification_provider: None,
12584            chain_id: None,
12585            mode,
12586            config,
12587            base_interests: Vec::new(),
12588            owned_interests: Vec::new(),
12589            next_owner_epoch: 0,
12590            interests: Vec::new(),
12591            log_source_ids: HashMap::new(),
12592            next_log_source_id: 0,
12593            pending_backfills: VecDeque::new(),
12594            pending_source_backfills: VecDeque::new(),
12595            sources_dirty: true,
12596            stream_revision: 0,
12597            state: AlloySubscriberState::Uninitialized,
12598            pending_records: VecDeque::new(),
12599            pending_chain_controls: VecDeque::new(),
12600            pending_reconcile_owner_records: VecDeque::new(),
12601            resource_error: None,
12602            last_seen_log_blocks: HashMap::new(),
12603            verified_log_blocks: HashMap::new(),
12604            verified_log_block_order: VecDeque::new(),
12605            recent_input_refs: VecDeque::new(),
12606            recent_input_ref_set: HashSet::new(),
12607            recent_owner_input_refs: HashMap::new(),
12608            recent_owner_input_ref_sets: HashMap::new(),
12609            recent_compat_owner_input_refs: HashMap::new(),
12610            recent_compat_owner_input_ref_sets: HashMap::new(),
12611            base_flashblock_header: None,
12612            base_flashblock_transactions: None,
12613            unmatched_pending_logs: VecDeque::new(),
12614            latest_preconfirmation: None,
12615            preconfirmed_seen_logs: HashSet::new(),
12616            preconfirmed_receipted_transactions: HashSet::new(),
12617            preconfirmed_unavailable_receipts: HashSet::new(),
12618            last_certified_canonical_head: None,
12619            pending_preconfirmation_invalidation: false,
12620            pending_flashblock_reconnects: FuturesUnordered::new(),
12621            pending_flashblock_reconnect_sources: Vec::new(),
12622            flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
12623            consecutive_flashblock_poll_failures: 0,
12624            flashblock_rpc_request_times: VecDeque::new(),
12625            _network: PhantomData,
12626        }
12627    }
12628
12629    /// Borrow the provider.
12630    pub fn provider(&self) -> &P {
12631        &self.provider
12632    }
12633
12634    /// Bind this subscriber to the concrete provider lease that supplies
12635    /// Flashblocks. Callers obtain the lease from a transport endpoint marked
12636    /// with the single `flashblocks = true` flag.
12637    #[must_use]
12638    pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
12639        self.provider_ref = Some(provider);
12640        self
12641    }
12642
12643    /// Select application-managed standardized Flashblock updates before
12644    /// subscriber registration begins.
12645    ///
12646    /// This suppresses the subscriber's chain-specific native or pending-state
12647    /// Flashblocks source. Canonical logs and block headers continue through the
12648    /// configured subscriber transport. The application owns the raw socket,
12649    /// control frames, bounded queue, timeout, retry, backoff, and provider
12650    /// rotation, and passes decoded updates to
12651    /// [`Self::ingest_flashblock_update`].
12652    ///
12653    /// Call [`Self::ingest_flashblock_update`] directly while retaining mutable
12654    /// subscriber ownership, or open a bounded handoff with
12655    /// [`Self::open_external_flashblock_update_channel`] before moving the
12656    /// subscriber into another runtime owner.
12657    ///
12658    /// This is deliberately a fallible construction-time configuration method,
12659    /// not a live reconfiguration API. Replacing a source after canonical or
12660    /// speculative processing begins requires a new subscriber so existing
12661    /// streams and overlays cannot survive under ambiguous provider ownership.
12662    ///
12663    /// # Errors
12664    ///
12665    /// Returns [`SubscriberError::InvalidConfig`] when an external source was
12666    /// already selected or subscriber registration, stream installation, or
12667    /// event processing has begun.
12668    #[cfg(feature = "raw-flashblocks-json")]
12669    pub fn configure_external_flashblock_updates(
12670        &mut self,
12671        provider: ProviderRef,
12672    ) -> Result<(), SubscriberError> {
12673        if self.external_flashblocks_provider.is_some() {
12674            return Err(SubscriberError::InvalidConfig(
12675                "external Flashblock updates were already configured",
12676            ));
12677        }
12678        if self.external_flashblock_update_channel_opened
12679            || self.external_flashblock_updates.is_some()
12680            || self.chain_id.is_some()
12681            || !self.base_interests.is_empty()
12682            || !self.owned_interests.is_empty()
12683            || !self.interests.is_empty()
12684            || !self.pending_records.is_empty()
12685            || !self.pending_chain_controls.is_empty()
12686            || !self.pending_backfills.is_empty()
12687            || !matches!(self.state, AlloySubscriberState::Uninitialized)
12688        {
12689            return Err(SubscriberError::InvalidConfig(
12690                "external Flashblock updates must be configured before subscriber registration",
12691            ));
12692        }
12693        self.external_flashblocks_provider = Some(provider);
12694        Ok(())
12695    }
12696
12697    /// Open one bounded standardized-update queue and return its application handle.
12698    ///
12699    /// The queue is useful when the subscriber will be moved into a runtime
12700    /// driver: the application retains the cloneable sender while the subscriber
12701    /// continues to own all validation, speculative deduplication, and canonical
12702    /// reconciliation. Opening a queue does not create a socket or background
12703    /// task, and does not implement retry or backoff. Awaited sends complete
12704    /// only after subscriber validation; non-blocking sends return an explicit
12705    /// acknowledgement receipt.
12706    ///
12707    /// # Errors
12708    ///
12709    /// Returns [`SubscriberError::InvalidConfig`] if external updates were not
12710    /// selected first, `capacity` is zero, or a queue was already opened.
12711    #[cfg(feature = "raw-flashblocks-json")]
12712    pub fn open_external_flashblock_update_channel(
12713        &mut self,
12714        capacity: usize,
12715    ) -> Result<FlashblockUpdateSender, SubscriberError> {
12716        if capacity == 0 {
12717            return Err(SubscriberError::InvalidConfig(
12718                "external Flashblock update channel capacity must be greater than zero",
12719            ));
12720        }
12721        let provider = self.external_flashblocks_provider.clone().ok_or(
12722            SubscriberError::InvalidConfig(
12723                "external Flashblock update channel requires configure_external_flashblock_updates",
12724            ),
12725        )?;
12726        if self.external_flashblock_update_channel_opened {
12727            return Err(SubscriberError::InvalidConfig(
12728                "external Flashblock update channel was already opened",
12729            ));
12730        }
12731        let (sender, receiver) =
12732            raw_json_flashblocks::flashblock_update_channel(provider, capacity);
12733        self.external_flashblock_updates = Some(receiver);
12734        self.external_flashblock_update_channel_opened = true;
12735        self.sources_dirty = true;
12736        Ok(sender)
12737    }
12738
12739    fn uses_external_flashblock_updates(&self) -> bool {
12740        #[cfg(feature = "raw-flashblocks-json")]
12741        {
12742            self.external_flashblocks_provider.is_some()
12743        }
12744        #[cfg(not(feature = "raw-flashblocks-json"))]
12745        {
12746            false
12747        }
12748    }
12749
12750    /// Pair the subscriber's event transport with the request/response
12751    /// transport for the same configured provider ID and generation.
12752    ///
12753    /// Optimism pending block/log sampling uses this provider. Preflight reads
12754    /// its chain ID and rejects a mismatch before pending data can be emitted.
12755    /// Use type-erased Alloy providers when the WebSocket and HTTP transports
12756    /// have different concrete Rust types.
12757    #[must_use]
12758    pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
12759        self.flashblocks_state_provider = Some(provider);
12760        self
12761    }
12762
12763    /// Use a separate provider for canonical log-context verification.
12764    ///
12765    /// This is recommended with
12766    /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub
12767    /// deployments. The provider must target the same chain; every fetched
12768    /// block is still checked against the log's number, hash, and timestamp.
12769    #[must_use]
12770    pub fn with_log_verification_provider(mut self, provider: P) -> Self {
12771        self.log_verification_provider = Some(provider);
12772        self
12773    }
12774
12775    /// Subscriber mode.
12776    pub fn mode(&self) -> SubscriberMode {
12777        self.mode
12778    }
12779
12780    /// Subscriber config.
12781    pub fn config(&self) -> &SubscriberConfig {
12782        &self.config
12783    }
12784
12785    /// Request/response traffic issued for Flashblocks qualification and
12786    /// pending-state sampling since the last full interest reset.
12787    pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
12788        self.flashblocks_rpc_metrics
12789    }
12790
12791    /// Registered interests across base and owner-scoped registrations.
12792    pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
12793        &self.interests
12794    }
12795
12796    /// Stage a fresh, epoch-scoped interest owner without making its inputs
12797    /// canonically routable yet.
12798    ///
12799    /// The returned token is required by every later lifecycle operation. A
12800    /// staged owner participates in provider subscription planning immediately,
12801    /// while its matching input remains owner-scoped until
12802    /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds.
12803    /// Post-block owners require hash-certified
12804    /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on
12805    /// the current clean stream revision before activation.
12806    ///
12807    /// # Errors
12808    ///
12809    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12810    /// duplicate owners, unsupported post-block interests, unsupported
12811    /// transport interests, block-number overflow, or epoch exhaustion.
12812    pub fn stage_interest_owner(
12813        &mut self,
12814        owner: HandlerId,
12815        interests: &[ReactiveInterest<N>],
12816        start: SubscriberOwnerStart,
12817    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12818        validate_subscriber_config(&self.config)?;
12819        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12820            && interests
12821                .iter()
12822                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12823        {
12824            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12825        }
12826        if self
12827            .owned_interests
12828            .iter()
12829            .any(|entry| entry.owner == owner)
12830        {
12831            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12832        }
12833
12834        let mut next_owned = self.clone_owned_interests();
12835        next_owned.push(OwnedSubscriberInterests {
12836            owner: owner.clone(),
12837            interests: interests.to_vec(),
12838            epoch: None,
12839            state: SubscriberOwnerState::Staged,
12840            baseline: None,
12841            progress: None,
12842            progress_stream_revision: None,
12843        });
12844        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12845        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12846
12847        let baseline = match start {
12848            SubscriberOwnerStart::Live => None,
12849            SubscriberOwnerStart::PostBlock(block) => {
12850                block
12851                    .number
12852                    .checked_add(1)
12853                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12854                Some(block)
12855            }
12856        };
12857        let sequence = self
12858            .next_owner_epoch
12859            .checked_add(1)
12860            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12861        let epoch = SubscriberOwnerEpoch {
12862            owner: owner.clone(),
12863            sequence,
12864        };
12865
12866        self.next_owner_epoch = sequence;
12867        let entry = next_owned
12868            .last_mut()
12869            .expect("staged owner was appended during preflight");
12870        entry.epoch = Some(epoch.clone());
12871        entry.baseline = baseline;
12872        self.owned_interests = next_owned;
12873        self.interests = next_registered;
12874        self.sources_dirty = true;
12875
12876        Ok(epoch)
12877    }
12878
12879    /// Stage replacement interests for one currently active logical owner.
12880    ///
12881    /// The active epoch remains canonical while the replacement reconciles.
12882    /// Commit both epochs atomically with
12883    /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement),
12884    /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner).
12885    ///
12886    /// # Errors
12887    ///
12888    /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration,
12889    /// missing/non-unique active owner state, unsupported post-block interests,
12890    /// unsupported transport interests, block-number overflow, or epoch
12891    /// exhaustion.
12892    pub fn stage_interest_owner_replacement(
12893        &mut self,
12894        owner: HandlerId,
12895        interests: &[ReactiveInterest<N>],
12896        start: SubscriberOwnerStart,
12897    ) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
12898        validate_subscriber_config(&self.config)?;
12899        if matches!(&start, SubscriberOwnerStart::PostBlock(_))
12900            && interests
12901                .iter()
12902                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
12903        {
12904            return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
12905        }
12906        let active_count = self
12907            .owned_interests
12908            .iter()
12909            .filter(|entry| {
12910                entry.owner == owner
12911                    && entry.state == SubscriberOwnerState::Active
12912                    && entry.epoch.is_some()
12913            })
12914            .count();
12915        if active_count != 1
12916            || self
12917                .owned_interests
12918                .iter()
12919                .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
12920        {
12921            return Err(SubscriberOwnerError::AlreadyRegistered(owner));
12922        }
12923
12924        let mut next_owned = self.clone_owned_interests();
12925        next_owned.push(OwnedSubscriberInterests {
12926            owner: owner.clone(),
12927            interests: interests.to_vec(),
12928            epoch: None,
12929            state: SubscriberOwnerState::Staged,
12930            baseline: None,
12931            progress: None,
12932            progress_stream_revision: None,
12933        });
12934        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
12935        validate_supported_interests(self.mode, &self.config, &next_registered)?;
12936
12937        let baseline = match start {
12938            SubscriberOwnerStart::Live => None,
12939            SubscriberOwnerStart::PostBlock(block) => {
12940                block
12941                    .number
12942                    .checked_add(1)
12943                    .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
12944                Some(block)
12945            }
12946        };
12947        let sequence = self
12948            .next_owner_epoch
12949            .checked_add(1)
12950            .ok_or(SubscriberOwnerError::EpochExhausted)?;
12951        let epoch = SubscriberOwnerEpoch {
12952            owner: owner.clone(),
12953            sequence,
12954        };
12955
12956        self.next_owner_epoch = sequence;
12957        let entry = next_owned
12958            .last_mut()
12959            .expect("staged replacement owner was appended during preflight");
12960        entry.epoch = Some(epoch.clone());
12961        entry.baseline = baseline;
12962        self.owned_interests = next_owned;
12963        self.interests = next_registered;
12964        self.sources_dirty = true;
12965        Ok(epoch)
12966    }
12967
12968    /// Current transaction state for an exact owner epoch.
12969    pub fn interest_owner_state(
12970        &self,
12971        epoch: &SubscriberOwnerEpoch,
12972    ) -> Option<SubscriberOwnerState> {
12973        self.owned_interests
12974            .iter()
12975            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12976            .map(|entry| entry.state)
12977    }
12978
12979    /// Latest hash-certified reconcile progress for an exact owner epoch.
12980    pub fn interest_owner_progress(
12981        &self,
12982        epoch: &SubscriberOwnerEpoch,
12983    ) -> Option<&SubscriberOwnerProgress> {
12984        self.owned_interests
12985            .iter()
12986            .find(|entry| entry.epoch.as_ref() == Some(epoch))
12987            .and_then(|entry| entry.progress.as_ref())
12988    }
12989
12990    /// Make a staged owner canonical after its actor-side installation commits.
12991    ///
12992    /// Returns `false` for stale tokens and owners not currently staged.
12993    pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
12994        let stream_revision = self.stream_revision;
12995        let sources_dirty = self.sources_dirty;
12996        let Some(entry) = self
12997            .owned_interests
12998            .iter_mut()
12999            .find(|entry| entry.epoch.as_ref() == Some(epoch))
13000        else {
13001            return false;
13002        };
13003        if entry.state != SubscriberOwnerState::Staged
13004            || (entry.baseline.is_some()
13005                && (entry.progress.is_none()
13006                    || entry.progress_stream_revision != Some(stream_revision)
13007                    || sources_dirty))
13008        {
13009            return false;
13010        }
13011        entry.state = SubscriberOwnerState::Active;
13012        true
13013    }
13014
13015    /// Atomically replace one active owner epoch with one reconciled staged epoch.
13016    pub fn commit_interest_owner_replacement(
13017        &mut self,
13018        active: &SubscriberOwnerEpoch,
13019        replacement: &SubscriberOwnerEpoch,
13020    ) -> bool {
13021        let Some(active_index) = self
13022            .owned_interests
13023            .iter()
13024            .position(|entry| entry.epoch.as_ref() == Some(active))
13025        else {
13026            return false;
13027        };
13028        let Some(replacement_index) = self
13029            .owned_interests
13030            .iter()
13031            .position(|entry| entry.epoch.as_ref() == Some(replacement))
13032        else {
13033            return false;
13034        };
13035        if active_index == replacement_index
13036            || active.owner() != replacement.owner()
13037            || self.owned_interests[active_index].state != SubscriberOwnerState::Active
13038            || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
13039            || (self.owned_interests[replacement_index].baseline.is_some()
13040                && (self.owned_interests[replacement_index].progress.is_none()
13041                    || self.owned_interests[replacement_index].progress_stream_revision
13042                        != Some(self.stream_revision)
13043                    || self.sources_dirty))
13044        {
13045            return false;
13046        }
13047
13048        self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
13049        self.owned_interests.remove(active_index);
13050        self.purge_owner_epoch(active);
13051        self.rebuild_registered_interests();
13052        self.retire_unreferenced_filters();
13053        self.sources_dirty = true;
13054        true
13055    }
13056
13057    /// Prepare an exact active owner for removal without changing desired
13058    /// interests, streams, anchors, or queued canonical input.
13059    ///
13060    /// The caller establishes its delivery fence after this transition. Use
13061    /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner
13062    /// on actor-side failure, or
13063    /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal)
13064    /// once canonical routing has been removed.
13065    pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
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::Active {
13074            return false;
13075        }
13076        entry.state = SubscriberOwnerState::Removing;
13077        true
13078    }
13079
13080    /// Finalize a previously prepared exact owner removal.
13081    ///
13082    /// Returns the removed interests, or `None` for stale tokens and owners not
13083    /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization
13084    /// is therefore idempotent.
13085    pub fn finalize_interest_owner_removal(
13086        &mut self,
13087        epoch: &SubscriberOwnerEpoch,
13088    ) -> Option<Vec<ReactiveInterest<N>>> {
13089        let index = self.owned_interests.iter().position(|entry| {
13090            entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
13091        })?;
13092        let removed = self.owned_interests.remove(index).interests;
13093        self.purge_owner_epoch(epoch);
13094        self.rebuild_registered_interests();
13095        self.retire_unreferenced_filters();
13096        self.sources_dirty = true;
13097        Some(removed)
13098    }
13099
13100    /// Abort an epoch-scoped owner lifecycle operation.
13101    ///
13102    /// A staged owner is removed completely. A prepared removal is restored to
13103    /// active. Active and unknown epochs are unchanged. Repeating the same
13104    /// abort is therefore safe and returns `false` after the first effect.
13105    pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
13106        let Some(index) = self
13107            .owned_interests
13108            .iter()
13109            .position(|entry| entry.epoch.as_ref() == Some(epoch))
13110        else {
13111            return false;
13112        };
13113        match self.owned_interests[index].state {
13114            SubscriberOwnerState::Staged => {
13115                self.owned_interests.remove(index);
13116                self.purge_owner_epoch(epoch);
13117                self.rebuild_registered_interests();
13118                self.retire_unreferenced_filters();
13119                self.sources_dirty = true;
13120                true
13121            }
13122            SubscriberOwnerState::Removing => {
13123                self.owned_interests[index].state = SubscriberOwnerState::Active;
13124                true
13125            }
13126            SubscriberOwnerState::Active => false,
13127        }
13128    }
13129
13130    fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
13131        self.pending_backfills
13132            .retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
13133        self.pending_records
13134            .retain_mut(|pending| match &mut pending.scope {
13135                SubscriberInputScope::Canonical { owners }
13136                | SubscriberInputScope::CanonicalResidual { owners, .. } => {
13137                    owners.retain(|owner| owner != epoch);
13138                    true
13139                }
13140                SubscriberInputScope::OwnerOnly { owners } => {
13141                    owners.retain(|owner| owner != epoch);
13142                    !owners.is_empty()
13143                }
13144                SubscriberInputScope::OwnerOnlyHandlers { .. }
13145                | SubscriberInputScope::Preconfirmed => true,
13146            });
13147        self.pending_reconcile_owner_records.retain_mut(|pending| {
13148            pending.owners.retain(|owner| owner != epoch);
13149            !pending.owners.is_empty()
13150        });
13151        self.recent_owner_input_refs.remove(epoch);
13152        self.recent_owner_input_ref_sets.remove(epoch);
13153    }
13154
13155    /// Atomically add or replace several owners while preserving unrelated ones.
13156    ///
13157    /// # Errors
13158    ///
13159    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13160    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13161    /// exhaustion. No owner state changes on error.
13162    pub fn upsert_interest_owners(
13163        &mut self,
13164        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13165    ) -> Result<(), SubscriberError> {
13166        self.upsert_interest_owners_inner(owners, None)
13167    }
13168
13169    /// Atomically add or replace several owners and queue one common backfill
13170    /// policy for every log interest while preserving unrelated owners.
13171    ///
13172    /// # Errors
13173    ///
13174    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13175    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13176    /// exhaustion. No owner or backfill state changes on error.
13177    pub fn upsert_interest_owners_with_backfill(
13178        &mut self,
13179        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13180        backfill: SubscriberBackfill,
13181    ) -> Result<(), SubscriberError> {
13182        self.upsert_interest_owners_inner(owners, Some(backfill))
13183    }
13184
13185    fn upsert_interest_owners_inner(
13186        &mut self,
13187        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13188        explicit_backfill: Option<SubscriberBackfill>,
13189    ) -> Result<(), SubscriberError> {
13190        validate_subscriber_config(&self.config)?;
13191        let mut seen = HashSet::with_capacity(owners.len());
13192        let mut next_owned = self.clone_owned_interests();
13193        for (owner, interests) in &owners {
13194            if !seen.insert(owner.clone()) {
13195                return Err(SubscriberError::InvalidConfig(
13196                    "bulk owner upsert contains a duplicate owner",
13197                ));
13198            }
13199            if self
13200                .owned_interests
13201                .iter()
13202                .any(|entry| &entry.owner == owner && entry.epoch.is_some())
13203            {
13204                return Err(SubscriberError::InvalidConfig(
13205                    "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13206                ));
13207            }
13208            if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
13209                entry.interests = interests.clone();
13210                entry.state = SubscriberOwnerState::Active;
13211                entry.baseline = None;
13212                entry.progress = None;
13213                entry.progress_stream_revision = None;
13214            } else {
13215                next_owned.push(OwnedSubscriberInterests {
13216                    owner: owner.clone(),
13217                    interests: interests.clone(),
13218                    epoch: None,
13219                    state: SubscriberOwnerState::Active,
13220                    baseline: None,
13221                    progress: None,
13222                    progress_stream_revision: None,
13223                });
13224            }
13225        }
13226        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13227        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13228
13229        // Build every owner's replacement queue before the first mutation.
13230        // Besides keeping capacity failure atomic, this preserves continuity
13231        // for changed filter shapes when the caller did not provide a common
13232        // open-ended backfill that already covers the old delivery anchor.
13233        let mut replacement_backfills = Vec::new();
13234        for (owner, interests) in &owners {
13235            let previous_filters: Vec<Filter> = self
13236                .owner_interests(owner)
13237                .map(log_filters)
13238                .unwrap_or_default();
13239            let continuity_anchor = previous_filters
13240                .iter()
13241                .filter_map(|filter| self.log_anchor(filter))
13242                .min();
13243            let filters = log_filters(interests);
13244            if let Some(backfill) = explicit_backfill
13245                && !filters.is_empty()
13246            {
13247                replacement_backfills.push(QueuedSubscriberBackfill {
13248                    owner: Some(owner.clone()),
13249                    epoch: None,
13250                    filters: filters.clone(),
13251                    backfill,
13252                });
13253            }
13254            let explicit_covers = explicit_backfill.is_some_and(|explicit| {
13255                explicit.end_block().is_none()
13256                    && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
13257            });
13258            let continuity_filters: Vec<_> = filters
13259                .into_iter()
13260                .filter(|filter| !previous_filters.contains(filter))
13261                .collect();
13262            if let Some(anchor) = continuity_anchor
13263                && !continuity_filters.is_empty()
13264                && !explicit_covers
13265            {
13266                replacement_backfills.push(QueuedSubscriberBackfill {
13267                    owner: Some(owner.clone()),
13268                    epoch: None,
13269                    filters: continuity_filters,
13270                    backfill: SubscriberBackfill::from_block(anchor),
13271                });
13272            }
13273        }
13274
13275        let retained_backfills = self
13276            .pending_backfills
13277            .iter()
13278            .filter(|queued| {
13279                queued
13280                    .owner
13281                    .as_ref()
13282                    .is_none_or(|owner| !seen.contains(owner))
13283            })
13284            .map(|queued| queued.filters.len())
13285            .sum::<usize>();
13286        let replacement_units = replacement_backfills
13287            .iter()
13288            .map(|queued| queued.filters.len())
13289            .sum::<usize>();
13290        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13291        {
13292            return Err(SubscriberError::ResourceExhausted(format!(
13293                "bulk owner update would queue more than {} lazy backfills",
13294                self.config.max_pending_backfills
13295            )));
13296        }
13297
13298        // All validation and capacity checks are complete. The remaining
13299        // assignments have no failure or cancellation point, so topology and
13300        // historical work become authoritative as one local commit.
13301        self.owned_interests = next_owned;
13302        self.interests = next_registered;
13303        for owner in &seen {
13304            self.recent_compat_owner_input_refs.remove(owner);
13305            self.recent_compat_owner_input_ref_sets.remove(owner);
13306        }
13307        self.retire_unreferenced_filters();
13308        self.sources_dirty = true;
13309        self.pending_backfills.retain(|queued| {
13310            queued
13311                .owner
13312                .as_ref()
13313                .is_none_or(|owner| !seen.contains(owner))
13314        });
13315        self.pending_backfills.extend(replacement_backfills);
13316        Ok(())
13317    }
13318
13319    /// Atomically replace every compatibility owner without requesting
13320    /// historical delivery.
13321    ///
13322    /// # Errors
13323    ///
13324    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13325    /// mixed lifecycle APIs, unsupported interests, or resource exhaustion.
13326    /// The previous topology remains authoritative on error.
13327    pub fn replace_interest_owners(
13328        &mut self,
13329        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13330    ) -> Result<(), SubscriberError> {
13331        self.replace_interest_owners_inner(owners, None)
13332    }
13333
13334    /// Atomically replace every compatibility owner and queue one global
13335    /// post-baseline backfill for the resulting union of log interests.
13336    ///
13337    /// Base interests are replaced. Epoch-scoped lifecycle operations cannot
13338    /// be mixed with this compatibility replacement because silently deleting
13339    /// an in-flight epoch would violate its activation transaction.
13340    ///
13341    /// # Errors
13342    ///
13343    /// Returns [`SubscriberError`] for invalid configuration, duplicate owners,
13344    /// mixed lifecycle APIs, unsupported interests, or backfill-capacity
13345    /// exhaustion. The previous topology remains authoritative on error.
13346    pub fn replace_interest_owners_with_global_backfill(
13347        &mut self,
13348        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13349        backfill: SubscriberBackfill,
13350    ) -> Result<(), SubscriberError> {
13351        self.replace_interest_owners_inner(owners, Some(backfill))
13352    }
13353
13354    fn replace_interest_owners_inner(
13355        &mut self,
13356        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
13357        backfill: Option<SubscriberBackfill>,
13358    ) -> Result<(), SubscriberError> {
13359        validate_subscriber_config(&self.config)?;
13360        if self
13361            .owned_interests
13362            .iter()
13363            .any(|entry| entry.epoch.is_some())
13364        {
13365            return Err(SubscriberError::InvalidConfig(
13366                "cannot replace compatibility owners while an epoch-scoped lifecycle exists",
13367            ));
13368        }
13369
13370        let mut seen = HashSet::with_capacity(owners.len());
13371        let mut next_owned = Vec::with_capacity(owners.len());
13372        for (owner, interests) in owners {
13373            if !seen.insert(owner.clone()) {
13374                return Err(SubscriberError::InvalidConfig(
13375                    "owner replacement contains a duplicate owner",
13376                ));
13377            }
13378            next_owned.push(OwnedSubscriberInterests {
13379                owner,
13380                interests,
13381                epoch: None,
13382                state: SubscriberOwnerState::Active,
13383                baseline: None,
13384                progress: None,
13385                progress_stream_revision: None,
13386            });
13387        }
13388        let next_registered = aggregate_interests(&[], &next_owned);
13389        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13390        let mut filters = log_filters(&next_registered);
13391        let mut unique_filters = Vec::with_capacity(filters.len());
13392        for filter in filters.drain(..) {
13393            if !unique_filters.contains(&filter) {
13394                unique_filters.push(filter);
13395            }
13396        }
13397        let replacement_backfills: VecDeque<_> = match backfill {
13398            Some(backfill) if !unique_filters.is_empty() => {
13399                VecDeque::from([QueuedSubscriberBackfill {
13400                    owner: None,
13401                    epoch: None,
13402                    filters: unique_filters,
13403                    backfill,
13404                }])
13405            }
13406            Some(_) | None => VecDeque::new(),
13407        };
13408        let replacement_units = replacement_backfills
13409            .iter()
13410            .map(|queued| queued.filters.len())
13411            .sum::<usize>();
13412        if replacement_units > self.config.max_pending_backfills {
13413            return Err(SubscriberError::ResourceExhausted(format!(
13414                "owner replacement would queue more than {} lazy backfills",
13415                self.config.max_pending_backfills
13416            )));
13417        }
13418
13419        // No fallible work remains. The post-baseline range reconstructs every
13420        // delivery after the cache snapshot, so reset all stale delivery and
13421        // dedupe state from the prior topology before publishing the exact
13422        // replacement plus its global historical work.
13423        let revoke_preconfirmation = self.latest_preconfirmation.is_some()
13424            || self.pending_preconfirmation_invalidation
13425            || self.pending_records.iter().any(|record| {
13426                record.scope == SubscriberInputScope::Preconfirmed
13427                    || matches!(
13428                        &record.record.context.chain_status,
13429                        ChainStatus::Preconfirmed { .. }
13430                    )
13431            });
13432        self.base_interests.clear();
13433        self.owned_interests = next_owned;
13434        self.interests = next_registered;
13435        self.reset_delivery_state();
13436        self.pending_preconfirmation_invalidation = revoke_preconfirmation;
13437        self.pending_backfills = replacement_backfills;
13438        self.reset_stream_topology();
13439        Ok(())
13440    }
13441
13442    /// Add or replace the interests owned by `owner`.
13443    ///
13444    /// This preserves unrelated owners, queued/pending records, recent dedupe
13445    /// state, and last-seen log anchors. The live transport is reconciled on the
13446    /// next [`EventSubscriber::next_batch`] call so newly added log filters can
13447    /// be subscribed without rebuilding the whole subscriber object.
13448    ///
13449    /// Replacing an existing owner is continuity-safe: filters the owner
13450    /// already had keep their delivery anchors, and any changed or new filter
13451    /// shape is automatically backfilled from the owner's oldest prior anchor —
13452    /// growing a pool set on an established owner does not open a delivery gap
13453    /// for what the old subscription had already covered. A brand-new owner has
13454    /// no anchor to inherit; pass an explicit
13455    /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill)
13456    /// anchor (or register through [`ReactiveEngine::register_handler`], which
13457    /// anchors to the runtime's last canonical block).
13458    ///
13459    /// # Errors
13460    ///
13461    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13462    /// lifecycle state, unsupported interests, or continuity-backfill capacity
13463    /// exhaustion. The prior owner state remains authoritative on error.
13464    pub fn add_interest_owner(
13465        &mut self,
13466        owner: HandlerId,
13467        interests: &[ReactiveInterest<N>],
13468    ) -> Result<(), SubscriberError> {
13469        self.set_interest_owner(owner, interests, None)
13470    }
13471
13472    /// Add or replace owner interests and schedule log backfill for that owner.
13473    ///
13474    /// Backfill is queued only for log interests; block and pending transaction
13475    /// interests are live-only. Queued records can be delivered immediately;
13476    /// the subsequent provider stream is then caught up from the seeded
13477    /// delivery anchor, and overlap is deduplicated — so the discovery boundary
13478    /// is closed end to end as long
13479    /// as `backfill` starts at (or before) the block the interest was
13480    /// discovered in. Continuity backfill for a replaced owner (see
13481    /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition,
13482    /// unless this explicit backfill is open-ended and already starts at or
13483    /// below the owner's prior anchor.
13484    ///
13485    /// # Errors
13486    ///
13487    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13488    /// lifecycle state, unsupported interests, or backfill-capacity exhaustion.
13489    /// The prior owner state remains authoritative on error.
13490    pub fn add_interest_owner_with_backfill(
13491        &mut self,
13492        owner: HandlerId,
13493        interests: &[ReactiveInterest<N>],
13494        backfill: SubscriberBackfill,
13495    ) -> Result<(), SubscriberError> {
13496        self.set_interest_owner(owner, interests, Some(backfill))
13497    }
13498
13499    /// Add or replace one owner at retained canonical block `C`, then queue the
13500    /// coordinated cutover required by [`ReactiveEngine::register_handler`].
13501    ///
13502    /// The new owner alone receives matching records from `C` so its effects
13503    /// attach to the runtime's existing journal entry. Every matching log from
13504    /// `C + 1` through the activation head is then delivered canonically over
13505    /// the complete interest union. [`Self::next_scoped_batch`] installs the
13506    /// desired live streams before draining either window, closing the
13507    /// subscribe/backfill gap. Alloy cannot reconstruct historical block or
13508    /// pending-transaction deliveries through this log backfill path, so a
13509    /// mixed interest topology is rejected rather than silently underfilled.
13510    ///
13511    /// # Errors
13512    ///
13513    /// Returns [`SubscriberError`] for invalid configuration, incompatible
13514    /// lifecycle state, unsupported non-log catch-up, block-number overflow, or
13515    /// resource exhaustion. The prior owner state remains authoritative on
13516    /// error.
13517    pub fn add_interest_owner_with_canonical_catchup(
13518        &mut self,
13519        owner: HandlerId,
13520        interests: &[ReactiveInterest<N>],
13521        retained: BlockRef,
13522    ) -> Result<(), SubscriberError> {
13523        validate_subscriber_config(&self.config)?;
13524        if self
13525            .owned_interests
13526            .iter()
13527            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13528        {
13529            return Err(SubscriberError::InvalidConfig(
13530                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13531            ));
13532        }
13533
13534        let mut next_owned = self.clone_owned_interests();
13535        if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
13536            entry.interests = interests.to_vec();
13537            entry.state = SubscriberOwnerState::Active;
13538            entry.baseline = None;
13539            entry.progress = None;
13540            entry.progress_stream_revision = None;
13541            entry.epoch = None;
13542        } else {
13543            next_owned.push(OwnedSubscriberInterests {
13544                owner: owner.clone(),
13545                interests: interests.to_vec(),
13546                epoch: None,
13547                state: SubscriberOwnerState::Active,
13548                baseline: None,
13549                progress: None,
13550                progress_stream_revision: None,
13551            });
13552        }
13553        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13554        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13555        if next_registered
13556            .iter()
13557            .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
13558        {
13559            return Err(SubscriberError::Unsupported(
13560                "Alloy coordinated registration supports log-only interest topologies",
13561            ));
13562        }
13563
13564        let mut owner_filters = Vec::new();
13565        for filter in log_filters(interests) {
13566            if !owner_filters.contains(&filter) {
13567                owner_filters.push(filter);
13568            }
13569        }
13570        let mut global_filters = Vec::new();
13571        for filter in log_filters(&next_registered) {
13572            if !global_filters.contains(&filter) {
13573                global_filters.push(filter);
13574            }
13575        }
13576        let owner_backfill =
13577            SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
13578        let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
13579        let replacement_units = owner_filters.len().saturating_add(global_filters.len());
13580        let retained_units = self
13581            .pending_backfills
13582            .iter()
13583            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13584            .map(|queued| queued.filters.len())
13585            .sum::<usize>();
13586        if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
13587            return Err(SubscriberError::ResourceExhausted(format!(
13588                "coordinated owner registration would queue more than {} lazy backfills",
13589                self.config.max_pending_backfills
13590            )));
13591        }
13592
13593        let mut replacement_backfills = VecDeque::new();
13594        if !owner_filters.is_empty() {
13595            replacement_backfills.push_back(QueuedSubscriberBackfill {
13596                owner: Some(owner.clone()),
13597                epoch: None,
13598                filters: owner_filters,
13599                backfill: owner_backfill,
13600            });
13601        }
13602        // Keep the global certification job even for an empty filter union: it
13603        // advances canonical coverage through a zero-event registration window.
13604        replacement_backfills.push_back(QueuedSubscriberBackfill {
13605            owner: None,
13606            epoch: None,
13607            filters: global_filters,
13608            backfill: global_backfill,
13609        });
13610
13611        // Every fallible preflight is complete. Publish topology and both
13612        // ordered windows as one synchronous local commit.
13613        self.owned_interests = next_owned;
13614        self.interests = next_registered;
13615        self.recent_compat_owner_input_refs.remove(&owner);
13616        self.recent_compat_owner_input_ref_sets.remove(&owner);
13617        self.pending_backfills
13618            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13619        self.pending_backfills.extend(replacement_backfills);
13620        self.retire_unreferenced_filters();
13621        self.sources_dirty = true;
13622        Ok(())
13623    }
13624
13625    /// Remove one owner's interests, preserving unrelated owner/base interests.
13626    ///
13627    /// The owner's queued backfills are dropped, and source-id/anchor
13628    /// bookkeeping for filters no other owner references is retired. Live
13629    /// streams for retired filters are torn down on the next
13630    /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription
13631    /// unsubscribes provider-side); events already in flight from them stop
13632    /// matching the merged interest set and are discarded.
13633    pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
13634        let index = self
13635            .owned_interests
13636            .iter()
13637            .position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
13638        let removed = self.owned_interests.remove(index);
13639        if let Some(epoch) = &removed.epoch {
13640            self.purge_owner_epoch(epoch);
13641        } else {
13642            self.pending_backfills
13643                .retain(|backfill| backfill.owner.as_ref() != Some(owner));
13644            self.recent_compat_owner_input_refs.remove(owner);
13645            self.recent_compat_owner_input_ref_sets.remove(owner);
13646        }
13647        self.rebuild_registered_interests();
13648        self.retire_unreferenced_filters();
13649        self.sources_dirty = true;
13650        Some(removed.interests)
13651    }
13652
13653    /// Borrow the interests currently owned by `owner`.
13654    pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
13655        self.owned_interests
13656            .iter()
13657            .find(|entry| &entry.owner == owner)
13658            .map(|entry| entry.interests.as_slice())
13659    }
13660
13661    fn set_interest_owner(
13662        &mut self,
13663        owner: HandlerId,
13664        interests: &[ReactiveInterest<N>],
13665        backfill: Option<SubscriberBackfill>,
13666    ) -> Result<(), SubscriberError> {
13667        validate_subscriber_config(&self.config)?;
13668        if self
13669            .owned_interests
13670            .iter()
13671            .any(|entry| entry.owner == owner && entry.epoch.is_some())
13672        {
13673            return Err(SubscriberError::InvalidConfig(
13674                "cannot mix compatibility and epoch-scoped owner lifecycle APIs",
13675            ));
13676        }
13677
13678        let mut next_owned = self.clone_owned_interests();
13679        let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
13680            Some(entry) => {
13681                entry.interests = interests.to_vec();
13682                entry.state = SubscriberOwnerState::Active;
13683                entry.baseline = None;
13684                entry.progress = None;
13685                entry.progress_stream_revision = None;
13686                entry.epoch.take()
13687            }
13688            None => {
13689                next_owned.push(OwnedSubscriberInterests {
13690                    owner: owner.clone(),
13691                    interests: interests.to_vec(),
13692                    epoch: None,
13693                    state: SubscriberOwnerState::Active,
13694                    baseline: None,
13695                    progress: None,
13696                    progress_stream_revision: None,
13697                });
13698                None
13699            }
13700        };
13701        let next_registered = aggregate_interests(&self.base_interests, &next_owned);
13702        validate_supported_interests(self.mode, &self.config, &next_registered)?;
13703
13704        // Continuity capture, before the mutation lands: the owner's previous
13705        // filter shapes and the oldest delivery anchor among them. A changed
13706        // filter gets a fresh source id with no anchor, so without this
13707        // hand-off, replacing an owner's interests (the normal way to grow a
13708        // pool set) would silently discard the delivery watermark and open a
13709        // gap until some later explicit backfill.
13710        let previous_filters: Vec<Filter> = self
13711            .owner_interests(&owner)
13712            .map(log_filters)
13713            .unwrap_or_default();
13714        let continuity_anchor: Option<u64> = previous_filters
13715            .iter()
13716            .filter_map(|filter| self.log_anchor(filter))
13717            .min();
13718
13719        // Build the replacement queue before committing owner state. Capacity
13720        // failure is therefore atomic and cannot leave desired interests ahead
13721        // of the historical work required to make them continuous.
13722        let mut replacement_backfills = Vec::new();
13723        let filters = log_filters(interests);
13724        if let Some(backfill) = backfill
13725            && !filters.is_empty()
13726        {
13727            replacement_backfills.push(QueuedSubscriberBackfill {
13728                owner: Some(owner.clone()),
13729                epoch: None,
13730                filters: filters.clone(),
13731                backfill,
13732            });
13733        }
13734        let explicit_covers = backfill.is_some_and(|explicit| {
13735            explicit.end_block().is_none()
13736                && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
13737        });
13738        let continuity_filters: Vec<_> = filters
13739            .into_iter()
13740            .filter(|filter| !previous_filters.contains(filter))
13741            .collect();
13742        if let Some(anchor) = continuity_anchor
13743            && !continuity_filters.is_empty()
13744            && !explicit_covers
13745        {
13746            replacement_backfills.push(QueuedSubscriberBackfill {
13747                owner: Some(owner.clone()),
13748                epoch: None,
13749                filters: continuity_filters,
13750                backfill: SubscriberBackfill::from_block(anchor),
13751            });
13752        }
13753        let retained_backfills = self
13754            .pending_backfills
13755            .iter()
13756            .filter(|queued| queued.owner.as_ref() != Some(&owner))
13757            .map(|queued| queued.filters.len())
13758            .sum::<usize>();
13759        let replacement_units = replacement_backfills
13760            .iter()
13761            .map(|queued| queued.filters.len())
13762            .sum::<usize>();
13763        if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
13764        {
13765            return Err(SubscriberError::ResourceExhausted(format!(
13766                "owner update would queue more than {} lazy backfills",
13767                self.config.max_pending_backfills
13768            )));
13769        }
13770
13771        self.owned_interests = next_owned;
13772        self.interests = next_registered;
13773        if let Some(epoch) = replaced_epoch {
13774            self.purge_owner_epoch(&epoch);
13775        } else {
13776            self.recent_compat_owner_input_refs.remove(&owner);
13777            self.recent_compat_owner_input_ref_sets.remove(&owner);
13778        }
13779        self.retire_unreferenced_filters();
13780        self.sources_dirty = true;
13781
13782        // Re-queue this owner's backfills from scratch: previously queued
13783        // entries may reference filter shapes that no longer exist.
13784        self.pending_backfills
13785            .retain(|queued| queued.owner.as_ref() != Some(&owner));
13786        self.pending_backfills.extend(replacement_backfills);
13787        Ok(())
13788    }
13789
13790    fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
13791        self.owned_interests
13792            .iter()
13793            .map(|entry| OwnedSubscriberInterests {
13794                owner: entry.owner.clone(),
13795                interests: entry.interests.clone(),
13796                epoch: entry.epoch.clone(),
13797                state: entry.state,
13798                baseline: entry.baseline,
13799                progress: entry.progress.clone(),
13800                progress_stream_revision: entry.progress_stream_revision,
13801            })
13802            .collect()
13803    }
13804
13805    fn rebuild_registered_interests(&mut self) {
13806        self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
13807    }
13808
13809    /// Delivery anchor (last block known fully delivered) for `filter`, if the
13810    /// filter has a source id and has seen delivery.
13811    fn log_anchor(&self, filter: &Filter) -> Option<u64> {
13812        if let Some(anchor) = self
13813            .log_source_ids
13814            .get(filter)
13815            .and_then(|id| self.last_seen_log_blocks.get(id))
13816        {
13817            return Some(*anchor);
13818        }
13819
13820        // Logical owner filters may be represented by a broader provider
13821        // stream after fan-in. Its oldest live watermark is a conservative
13822        // continuity anchor: it can cause extra backfill, never a missed log.
13823        self.log_source_ids
13824            .values()
13825            .filter_map(|id| self.last_seen_log_blocks.get(id).copied())
13826            .min()
13827    }
13828
13829    /// Every logical log filter across base and owner interests, merged within
13830    /// each origin and deduplicated across origins. These shapes remain the
13831    /// exact routing and owner-continuity boundary; provider subscriptions may
13832    /// fan several of them into one broader filter.
13833    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13834    // `mutable_key_type` lint is a known false positive for it.
13835    #[allow(clippy::mutable_key_type)]
13836    fn logical_log_filters(&self) -> Vec<Filter> {
13837        let mut filters = log_filters(&self.base_interests);
13838        for entry in &self.owned_interests {
13839            filters.extend(log_filters(&entry.interests));
13840        }
13841        let mut seen = HashSet::new();
13842        filters.retain(|filter| seen.insert(filter.clone()));
13843        filters
13844    }
13845
13846    /// Provider-facing log filters. Compatible logical filters fan into a
13847    /// small number of address/topic supersets, then split only when the
13848    /// configured address ceiling requires it. Exact matching remains local in
13849    /// `enqueue_event`, so this reduces subscriptions without broadening owner
13850    /// delivery.
13851    fn log_stream_filters(&self) -> Vec<Filter> {
13852        let mut merged = Vec::new();
13853        for filter in self.logical_log_filters() {
13854            merge_log_subscription_filter(&mut merged, &filter);
13855        }
13856
13857        let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
13858        let mut planned = Vec::new();
13859        for filter in merged {
13860            let mut addresses: Vec<_> = filter.address.iter().copied().collect();
13861            if addresses.len() <= max_addresses {
13862                planned.push(filter);
13863                continue;
13864            }
13865            addresses.sort_unstable();
13866            for chunk in addresses.chunks(max_addresses) {
13867                let mut split = filter.clone();
13868                split.address = FilterSet::default();
13869                for address in chunk {
13870                    split.address.insert(*address);
13871                }
13872                planned.push(split);
13873            }
13874        }
13875        planned
13876    }
13877
13878    /// Drop source-id and anchor bookkeeping for filters no longer referenced
13879    /// by any base or owner interest, so long-lived owner churn cannot grow the
13880    /// maps unboundedly. Live streams for retired filters are pruned by the
13881    /// next reconcile.
13882    // `Filter` derives `Hash`/`Eq` and has no interior mutability; the
13883    // `mutable_key_type` lint is a known false positive for it.
13884    #[allow(clippy::mutable_key_type)]
13885    fn retire_unreferenced_filters(&mut self) {
13886        let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
13887        if let AlloySubscriberState::Active(streams) = &self.state {
13888            for entry in &streams.entries {
13889                match &entry.source {
13890                    SubscriberStreamSource::PubSubLog { filter, .. }
13891                    | SubscriberStreamSource::BasePendingLog { filter, .. }
13892                    | SubscriberStreamSource::PollingLog { filter } => {
13893                        live.insert(filter.clone());
13894                    }
13895                    SubscriberStreamSource::BaseFlashblocks
13896                    | SubscriberStreamSource::OpPendingFlashblocks
13897                    | SubscriberStreamSource::CanonicalHeadPolling
13898                    | SubscriberStreamSource::PubSubPendingHashes
13899                    | SubscriberStreamSource::PubSubBlockHeaders
13900                    | SubscriberStreamSource::PollingPendingHashes => {}
13901                    #[cfg(feature = "raw-flashblocks-json")]
13902                    SubscriberStreamSource::ExternalFlashblockUpdates => {}
13903                }
13904            }
13905        }
13906        self.log_source_ids
13907            .retain(|filter, _| live.contains(filter));
13908        let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
13909        self.last_seen_log_blocks
13910            .retain(|id, _| live_ids.contains(id));
13911    }
13912
13913    fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
13914        if self.pending_records.is_empty()
13915            && self.pending_chain_controls.is_empty()
13916            && !self.pending_preconfirmation_invalidation
13917        {
13918            return None;
13919        }
13920
13921        let first_preconfirmation = self.pending_records.front().and_then(|record| {
13922            if record.scope != SubscriberInputScope::Preconfirmed {
13923                return None;
13924            }
13925            match &record.record.context.chain_status {
13926                ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
13927                _ => None,
13928            }
13929        });
13930        let len = self
13931            .pending_records
13932            .iter()
13933            .take(self.config.max_batch_size)
13934            .take_while(|record| match &first_preconfirmation {
13935                Some(expected) => {
13936                    record.scope == SubscriberInputScope::Preconfirmed
13937                        && matches!(
13938                            &record.record.context.chain_status,
13939                            ChainStatus::Preconfirmed { flashblock } if flashblock == expected
13940                        )
13941                }
13942                None => record.scope != SubscriberInputScope::Preconfirmed,
13943            })
13944            .count();
13945        let records = self.pending_records.drain(..len).collect();
13946        let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
13947            self.pending_chain_controls.drain(..).collect()
13948        } else {
13949            Vec::new()
13950        };
13951        Some(SubscriberInputBatch {
13952            records,
13953            chain_id: self.chain_id,
13954            chain_controls,
13955            preconfirmation_invalidated: std::mem::take(
13956                &mut self.pending_preconfirmation_invalidation,
13957            ),
13958        })
13959    }
13960
13961    fn reset_delivery_state(&mut self) {
13962        self.pending_records.clear();
13963        self.pending_chain_controls.clear();
13964        self.pending_reconcile_owner_records.clear();
13965        self.resource_error = None;
13966        self.last_seen_log_blocks.clear();
13967        self.verified_log_blocks.clear();
13968        self.verified_log_block_order.clear();
13969        self.recent_input_refs.clear();
13970        self.recent_input_ref_set.clear();
13971        self.recent_owner_input_refs.clear();
13972        self.recent_owner_input_ref_sets.clear();
13973        self.recent_compat_owner_input_refs.clear();
13974        self.recent_compat_owner_input_ref_sets.clear();
13975        self.pending_backfills.clear();
13976        self.pending_source_backfills.clear();
13977        self.pending_preconfirmation_invalidation = false;
13978        self.pending_flashblock_reconnects.clear();
13979        self.pending_flashblock_reconnect_sources.clear();
13980        self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
13981        self.log_source_ids.clear();
13982        self.next_log_source_id = 0;
13983        self.sources_dirty = true;
13984        self.last_certified_canonical_head = None;
13985        self.reset_flashblock_tracking();
13986    }
13987
13988    fn reset_stream_topology(&mut self) {
13989        #[cfg(feature = "raw-flashblocks-json")]
13990        let external = match &mut self.state {
13991            AlloySubscriberState::Active(streams) => streams
13992                .entries
13993                .iter()
13994                .position(|entry| entry.source.is_external_flashblocks())
13995                .map(|index| streams.entries.remove(index)),
13996            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
13997        };
13998
13999        #[cfg(feature = "raw-flashblocks-json")]
14000        if let Some(external) = external {
14001            let mut streams = SubscriberStreams::new();
14002            streams.entries.push(external);
14003            self.state = AlloySubscriberState::Active(streams);
14004            return;
14005        }
14006
14007        self.state = AlloySubscriberState::Uninitialized;
14008    }
14009
14010    fn reset_flashblock_tracking(&mut self) {
14011        self.base_flashblock_header = None;
14012        self.base_flashblock_transactions = None;
14013        self.unmatched_pending_logs.clear();
14014        self.latest_preconfirmation = None;
14015        self.preconfirmed_seen_logs.clear();
14016        self.preconfirmed_receipted_transactions.clear();
14017        self.preconfirmed_unavailable_receipts.clear();
14018        #[cfg(feature = "raw-flashblocks-json")]
14019        {
14020            self.last_external_flashblock_snapshot = None;
14021        }
14022        self.consecutive_flashblock_poll_failures = 0;
14023    }
14024
14025    /// Revoke only the active speculative snapshot while keeping the pinned
14026    /// provider session and its streams alive. A sampled OP pending view can
14027    /// legitimately be replaced, or a provider backend can briefly return an
14028    /// older cumulative view. Either observation makes the current signing
14029    /// authority unsafe, but does not prove that the transport generation is
14030    /// broken and should be reconnected.
14031    fn invalidate_preconfirmation_snapshot(&mut self) {
14032        self.pending_records
14033            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
14034        self.pending_preconfirmation_invalidation = true;
14035        self.latest_preconfirmation = None;
14036        self.preconfirmed_seen_logs.clear();
14037        self.preconfirmed_receipted_transactions.clear();
14038        self.preconfirmed_unavailable_receipts.clear();
14039    }
14040
14041    fn bump_stream_revision(&mut self) {
14042        self.stream_revision = self.stream_revision.saturating_add(1);
14043    }
14044}
14045
14046impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
14047where
14048    P: Provider<N> + Send + Sync,
14049    N: Network + 'static,
14050    N::HeaderResponse: Send + 'static,
14051{
14052    fn upsert_interest_owners(
14053        &mut self,
14054        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14055    ) -> SubscriberOperation<'_, ()> {
14056        Box::pin(async move {
14057            if !owners.is_empty() {
14058                self.ensure_chain_id().await?;
14059            }
14060            AlloySubscriber::upsert_interest_owners(self, owners)
14061        })
14062    }
14063
14064    fn replace_interest_owners(
14065        &mut self,
14066        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14067    ) -> SubscriberOperation<'_, ()> {
14068        Box::pin(async move {
14069            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
14070                self.ensure_chain_id().await?;
14071            }
14072            AlloySubscriber::replace_interest_owners(self, owners)
14073        })
14074    }
14075
14076    fn replace_interest_owners_with_global_backfill(
14077        &mut self,
14078        owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
14079        backfill: SubscriberBackfill,
14080    ) -> SubscriberOperation<'_, ()> {
14081        Box::pin(async move {
14082            if owners.iter().any(|(_, interests)| !interests.is_empty()) {
14083                self.ensure_chain_id().await?;
14084            }
14085            AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
14086        })
14087    }
14088
14089    fn add_interest_owner(
14090        &mut self,
14091        owner: HandlerId,
14092        interests: &[ReactiveInterest<N>],
14093    ) -> SubscriberOperation<'_, ()> {
14094        let interests = interests.to_vec();
14095        Box::pin(async move {
14096            if !interests.is_empty() {
14097                self.ensure_chain_id().await?;
14098            }
14099            AlloySubscriber::add_interest_owner(self, owner, &interests)
14100        })
14101    }
14102
14103    fn add_interest_owner_with_backfill(
14104        &mut self,
14105        owner: HandlerId,
14106        interests: &[ReactiveInterest<N>],
14107        backfill: SubscriberBackfill,
14108    ) -> SubscriberOperation<'_, ()> {
14109        let interests = interests.to_vec();
14110        Box::pin(async move {
14111            if !interests.is_empty() {
14112                self.ensure_chain_id().await?;
14113            }
14114            AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
14115        })
14116    }
14117
14118    fn add_interest_owner_with_canonical_catchup(
14119        &mut self,
14120        owner: HandlerId,
14121        interests: &[ReactiveInterest<N>],
14122        retained: BlockRef,
14123    ) -> SubscriberOperation<'_, ()> {
14124        let interests = interests.to_vec();
14125        Box::pin(async move {
14126            // Resolve provider identity before the synchronous topology commit;
14127            // cancellation or failure at this await leaves prior state intact.
14128            self.ensure_chain_id().await?;
14129            AlloySubscriber::add_interest_owner_with_canonical_catchup(
14130                self, owner, &interests, retained,
14131            )
14132        })
14133    }
14134
14135    fn remove_interest_owner(
14136        &mut self,
14137        owner: &HandlerId,
14138    ) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
14139        let owner = owner.clone();
14140        Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) })
14141    }
14142
14143    fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
14144        AlloySubscriber::owner_interests(self, owner)
14145    }
14146}
14147
14148enum AlloySubscriberState<N: Network> {
14149    Uninitialized,
14150    Active(SubscriberStreams<N>),
14151    Empty,
14152}
14153
14154struct SubscriberStreams<N: Network> {
14155    entries: Vec<SubscriberStreamEntry<N>>,
14156    next_index: usize,
14157}
14158
14159struct SubscriberStreamEntry<N: Network> {
14160    source: SubscriberStreamSource,
14161    stream: BoxStream<'static, SubscriberEvent<N>>,
14162}
14163
14164impl<N: Network> SubscriberStreams<N> {
14165    fn new() -> Self {
14166        Self {
14167            entries: Vec::new(),
14168            next_index: 0,
14169        }
14170    }
14171
14172    fn is_empty(&self) -> bool {
14173        self.entries.is_empty()
14174    }
14175
14176    fn push(
14177        &mut self,
14178        source: SubscriberStreamSource,
14179        stream: BoxStream<'static, SubscriberEvent<N>>,
14180    ) {
14181        self.entries.push(SubscriberStreamEntry { source, stream });
14182    }
14183
14184    #[cfg(test)]
14185    fn len(&self) -> usize {
14186        self.entries.len()
14187    }
14188
14189    fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
14190        self.entries
14191            .iter()
14192            .any(|entry| entry.source.same_key(source))
14193    }
14194
14195    fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
14196        self.entries
14197            .retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
14198        self.normalize_next_index();
14199    }
14200
14201    fn normalize_next_index(&mut self) {
14202        if self.entries.is_empty() {
14203            self.next_index = 0;
14204        } else if self.next_index >= self.entries.len() {
14205            self.next_index %= self.entries.len();
14206        }
14207    }
14208
14209    async fn next(&mut self) -> Option<SubscriberEvent<N>> {
14210        poll_fn(|cx| {
14211            self.normalize_next_index();
14212            if self.entries.is_empty() {
14213                return std::task::Poll::Ready(None);
14214            }
14215
14216            let mut index = self.next_index;
14217            let mut checked = 0usize;
14218            while checked < self.entries.len() {
14219                if index >= self.entries.len() {
14220                    index = 0;
14221                }
14222                match self.entries[index].stream.as_mut().poll_next(cx) {
14223                    std::task::Poll::Ready(Some(event)) => {
14224                        if matches!(event, SubscriberEvent::StreamTerminated(_)) {
14225                            self.entries.remove(index);
14226                            self.next_index = if self.entries.is_empty() {
14227                                0
14228                            } else {
14229                                index % self.entries.len()
14230                            };
14231                        } else {
14232                            self.next_index = (index + 1) % self.entries.len();
14233                        }
14234                        return std::task::Poll::Ready(Some(event));
14235                    }
14236                    std::task::Poll::Ready(None) => {
14237                        self.entries.remove(index);
14238                        if self.entries.is_empty() {
14239                            self.next_index = 0;
14240                            return std::task::Poll::Ready(None);
14241                        }
14242                    }
14243                    std::task::Poll::Pending => {
14244                        checked += 1;
14245                        index += 1;
14246                    }
14247                }
14248            }
14249
14250            if self.entries.is_empty() {
14251                std::task::Poll::Ready(None)
14252            } else {
14253                self.next_index = index % self.entries.len();
14254                std::task::Poll::Pending
14255            }
14256        })
14257        .await
14258    }
14259}
14260
14261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14262#[allow(dead_code)]
14263enum SubscriberTransport {
14264    PubSub,
14265    Polling,
14266}
14267
14268#[derive(Clone, Debug)]
14269enum SubscriberStreamSource {
14270    PubSubLog {
14271        id: usize,
14272        filter: Filter,
14273    },
14274    BasePendingLog {
14275        id: usize,
14276        filter: Filter,
14277    },
14278    BaseFlashblocks,
14279    OpPendingFlashblocks,
14280    CanonicalHeadPolling,
14281    PubSubPendingHashes,
14282    PubSubBlockHeaders,
14283    PollingLog {
14284        filter: Filter,
14285    },
14286    PollingPendingHashes,
14287    #[cfg(feature = "raw-flashblocks-json")]
14288    ExternalFlashblockUpdates,
14289}
14290
14291impl SubscriberStreamSource {
14292    fn label(&self) -> &'static str {
14293        match self {
14294            Self::PubSubLog { .. } => "pubsub log",
14295            Self::BasePendingLog { .. } => "OP Stack pendingLogs",
14296            Self::BaseFlashblocks => "OP Stack newFlashblocks",
14297            Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
14298            Self::CanonicalHeadPolling => "certified canonical head",
14299            Self::PubSubPendingHashes => "pubsub pending transaction hash",
14300            Self::PubSubBlockHeaders => "pubsub block header",
14301            Self::PollingLog { .. } => "polling log",
14302            Self::PollingPendingHashes => "polling pending transaction hash",
14303            #[cfg(feature = "raw-flashblocks-json")]
14304            Self::ExternalFlashblockUpdates => "external standardized Flashblock update",
14305        }
14306    }
14307
14308    fn is_pubsub(&self) -> bool {
14309        matches!(
14310            self,
14311            Self::PubSubLog { .. }
14312                | Self::BasePendingLog { .. }
14313                | Self::BaseFlashblocks
14314                | Self::OpPendingFlashblocks
14315                | Self::PubSubPendingHashes
14316                | Self::PubSubBlockHeaders
14317        )
14318    }
14319
14320    fn is_flashblocks(&self) -> bool {
14321        matches!(
14322            self,
14323            Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
14324        )
14325    }
14326
14327    fn same_key(&self, other: &Self) -> bool {
14328        match (self, other) {
14329            (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
14330            | (
14331                Self::BasePendingLog { filter: left, .. },
14332                Self::BasePendingLog { filter: right, .. },
14333            )
14334            | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
14335                left == right
14336            }
14337            (Self::BaseFlashblocks, Self::BaseFlashblocks)
14338            | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
14339            | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
14340            | (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
14341            | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
14342            | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
14343            #[cfg(feature = "raw-flashblocks-json")]
14344            (Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true,
14345            _ => false,
14346        }
14347    }
14348
14349    fn is_external_flashblocks(&self) -> bool {
14350        #[cfg(feature = "raw-flashblocks-json")]
14351        {
14352            matches!(self, Self::ExternalFlashblockUpdates)
14353        }
14354        #[cfg(not(feature = "raw-flashblocks-json"))]
14355        {
14356            false
14357        }
14358    }
14359}
14360
14361#[allow(dead_code)]
14362enum SubscriberEvent<N: Network> {
14363    Log {
14364        source_id: usize,
14365        log: Log,
14366    },
14367    BackfilledLogs {
14368        source_id: usize,
14369        logs: Vec<Log>,
14370    },
14371    Logs(Vec<Log>),
14372    BlockHeader(N::HeaderResponse),
14373    PendingHash(B256),
14374    PendingHashes(Vec<B256>),
14375    BasePendingLog {
14376        source_id: usize,
14377        log: Log,
14378    },
14379    BaseFlashblock(BaseFlashblockWirePayload),
14380    OpFlashblockTick,
14381    CanonicalHeadTick,
14382    PreconfirmedLogs {
14383        flashblock: FlashblockRef,
14384        logs: Vec<Log>,
14385    },
14386    FlashblockInvalidated,
14387    FlashblockObserved,
14388    #[cfg(feature = "raw-flashblocks-json")]
14389    ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate),
14390    StreamTerminated(SubscriberStreamSource),
14391}
14392
14393enum SubscriberReady<N: Network> {
14394    Event(Option<SubscriberEvent<N>>),
14395    FlashblockReconnect(
14396        SubscriberStreamSource,
14397        Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
14398    ),
14399}
14400
14401#[derive(Debug)]
14402enum PendingFlashblockPollError {
14403    Request(SubscriberError),
14404    Integrity(SubscriberError),
14405}
14406
14407impl PendingFlashblockPollError {
14408    fn into_subscriber(self) -> SubscriberError {
14409        match self {
14410            Self::Request(error) | Self::Integrity(error) => error,
14411        }
14412    }
14413}
14414
14415fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
14416    PendingFlashblockPollError::Request(provider_error(error))
14417}
14418
14419fn normalize_op_pending_block<N: Network>(
14420    mut value: serde_json::Value,
14421) -> Result<N::BlockResponse, SubscriberError> {
14422    let object = value.as_object_mut().ok_or_else(|| {
14423        SubscriberError::Provider("OP pending block response is not an object".into())
14424    })?;
14425    let transactions = object
14426        .get_mut("transactions")
14427        .and_then(serde_json::Value::as_array_mut)
14428        .ok_or_else(|| {
14429            SubscriberError::Provider(
14430                "OP pending block response is missing its transaction array".into(),
14431            )
14432        })?;
14433    for transaction in transactions {
14434        if transaction.is_string() {
14435            continue;
14436        }
14437        let hash = transaction
14438            .as_object()
14439            .and_then(|object| object.get("hash"))
14440            .filter(|hash| hash.is_string())
14441            .cloned()
14442            .ok_or_else(|| {
14443                SubscriberError::Provider("OP pending block transaction is missing its hash".into())
14444            })?;
14445        *transaction = hash;
14446    }
14447    if object.get("hash").is_none_or(serde_json::Value::is_null) {
14448        object.insert(
14449            "hash".into(),
14450            serde_json::Value::String(B256::ZERO.to_string()),
14451        );
14452    }
14453    if object.get("nonce").is_none_or(serde_json::Value::is_null) {
14454        object.insert(
14455            "nonce".into(),
14456            serde_json::Value::String("0x0000000000000000".into()),
14457        );
14458    }
14459    if object.get("miner").is_none_or(serde_json::Value::is_null)
14460        || object
14461            .get("beneficiary")
14462            .is_none_or(serde_json::Value::is_null)
14463    {
14464        object.insert(
14465            "miner".into(),
14466            serde_json::Value::String(Address::ZERO.to_string()),
14467        );
14468    }
14469    serde_json::from_value(value).map_err(|error| {
14470        SubscriberError::Provider(format!(
14471            "failed to decode normalized OP pending block: {error}"
14472        ))
14473    })
14474}
14475
14476fn normalize_pending_transaction_receipt(
14477    expected_transaction_hash: B256,
14478    value: serde_json::Value,
14479) -> Result<Option<Vec<Log>>, SubscriberError> {
14480    if value.is_null() {
14481        return Ok(None);
14482    }
14483    let receipt = value.as_object().ok_or_else(|| {
14484        SubscriberError::Provider("pending transaction receipt response is not an object".into())
14485    })?;
14486    let transaction_hash: B256 =
14487        serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
14488            SubscriberError::Provider(
14489                "pending transaction receipt is missing its transaction hash".into(),
14490            )
14491        })?)
14492        .map_err(|error| {
14493            SubscriberError::Provider(format!(
14494                "failed to decode pending transaction receipt hash: {error}"
14495            ))
14496        })?;
14497    if transaction_hash != expected_transaction_hash {
14498        return Err(SubscriberError::Provider(
14499            "pending transaction receipt hash disagrees with its request".into(),
14500        ));
14501    }
14502    let receipt_logs = receipt
14503        .get("logs")
14504        .and_then(serde_json::Value::as_array)
14505        .ok_or_else(|| {
14506            SubscriberError::Provider("pending transaction receipt is missing its log array".into())
14507        })?;
14508    let mut logs = Vec::new();
14509    for log in receipt_logs {
14510        let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
14511            SubscriberError::Provider(format!(
14512                "failed to decode pending transaction receipt log: {error}"
14513            ))
14514        })?;
14515        if log.transaction_hash != Some(expected_transaction_hash) {
14516            return Err(SubscriberError::Provider(
14517                "pending transaction receipt log hash disagrees with its receipt".into(),
14518            ));
14519        }
14520        logs.push(log);
14521    }
14522    Ok(Some(logs))
14523}
14524
14525impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
14526where
14527    P: Provider<N> + Send + Sync,
14528    N: Network + 'static,
14529    N::HeaderResponse: Send + 'static,
14530{
14531    fn chain_id(&self) -> Option<u64> {
14532        self.chain_id
14533    }
14534
14535    fn capabilities(&self) -> SubscriberCapabilities {
14536        let Ok(transport) = resolve_subscriber_transport(self.mode) else {
14537            return SubscriberCapabilities::default();
14538        };
14539        let mut capabilities = vec![
14540            SubscriberCapability::Logs,
14541            SubscriberCapability::PendingTransactionHashes,
14542            SubscriberCapability::HistoricalBackfill,
14543            SubscriberCapability::Live,
14544            SubscriberCapability::OwnerScopedDelivery,
14545            SubscriberCapability::DynamicInterests,
14546        ];
14547        if transport == SubscriberTransport::PubSub {
14548            capabilities.push(SubscriberCapability::BlockHeaders);
14549        }
14550        if self.config.preconfirmations != PreconfirmationMode::Disabled
14551            && (self.uses_external_flashblock_updates()
14552                || (self.provider_ref.is_some()
14553                    && self.chain_id.and_then(flashblocks_adapter).is_some()))
14554        {
14555            capabilities.push(SubscriberCapability::Preconfirmations);
14556        }
14557        SubscriberCapabilities::new(capabilities)
14558    }
14559
14560    fn register_interests(
14561        &mut self,
14562        interests: &[ReactiveInterest<N>],
14563    ) -> SubscriberOperation<'_, ()> {
14564        let interests = interests.to_vec();
14565        Box::pin(async move {
14566            validate_subscriber_config(&self.config)?;
14567            validate_supported_interests(self.mode, &self.config, &interests)?;
14568            if !interests.is_empty() {
14569                self.ensure_chain_id().await?;
14570            }
14571            self.validate_flashblocks_setup()?;
14572
14573            self.base_interests = interests;
14574            self.owned_interests.clear();
14575            self.rebuild_registered_interests();
14576            self.reset_delivery_state();
14577            self.reset_stream_topology();
14578            Ok(())
14579        })
14580    }
14581
14582    fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
14583        Box::pin(async {
14584            Ok(self
14585                .next_scoped_batch()
14586                .await?
14587                .map(SubscriberInputBatch::into_reactive_batch))
14588        })
14589    }
14590}
14591
14592impl<P, N> AlloySubscriber<P, N>
14593where
14594    P: Provider<N> + Send + Sync,
14595    N: Network + 'static,
14596    N::HeaderResponse: Send + 'static,
14597{
14598    /// Validate one configured provider generation and establish its selected
14599    /// Flashblocks delivery surface.
14600    ///
14601    /// The caller must register at least one active log interest first. The
14602    /// built-in profiles require a matching chain id and stable [`ProviderRef`].
14603    /// Base additionally requires pubsub, `newFlashblocks`, and one
14604    /// `pendingLogs` acknowledgement per planned provider filter. Optimism
14605    /// probes the bounded pending block/log/receipt surface.
14606    /// `op_supportedCapabilities` is queried opportunistically and retained as
14607    /// opaque evidence because provider implementations do not expose a uniform
14608    /// capability vocabulary.
14609    ///
14610    /// With `raw-flashblocks-json` and
14611    /// [`Self::configure_external_flashblock_updates`], preflight instead verifies
14612    /// the canonical subscriber chain and installed canonical stream topology.
14613    /// The application owns supplemental-source qualification, and this method
14614    /// performs no Flashblocks request/response calls for that profile.
14615    ///
14616    /// A successful return is deliberately not a liveness qualification. The
14617    /// acceptance window must still observe a Flashblock whose pending state
14618    /// advances and a correlated log for an active pool.
14619    pub async fn establish_flashblocks_preflight(
14620        &mut self,
14621        expected_chain_id: u64,
14622    ) -> Result<FlashblocksPreflight, SubscriberError> {
14623        validate_subscriber_config(&self.config)?;
14624        if self.config.preconfirmations == PreconfirmationMode::Disabled {
14625            return Err(SubscriberError::InvalidConfig(
14626                "Flashblocks preflight requires preconfirmations",
14627            ));
14628        }
14629        if !self
14630            .interests
14631            .iter()
14632            .any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
14633        {
14634            return Err(SubscriberError::InvalidConfig(
14635                "Flashblocks preflight requires at least one active log interest",
14636            ));
14637        }
14638        let chain_id = self.ensure_chain_id().await?;
14639        if chain_id != expected_chain_id {
14640            return Err(SubscriberError::ChainMismatch {
14641                expected: expected_chain_id,
14642                actual: chain_id,
14643            });
14644        }
14645        self.validate_flashblocks_setup()?;
14646        #[cfg(feature = "raw-flashblocks-json")]
14647        if let Some(provider) = self.external_flashblocks_provider.clone() {
14648            self.ensure_streams().await?;
14649            return Ok(FlashblocksPreflight {
14650                chain_id,
14651                provider,
14652                delivery: FlashblocksDelivery::ExternalUpdates,
14653                pending_log_subscriptions: 0,
14654                pending_log_filters: self.log_stream_filters().len(),
14655                advertised_capabilities: None,
14656            });
14657        }
14658        let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
14659            "Flashblocks are currently implemented for Base and OP chains",
14660        ))?;
14661        let provider = self
14662            .provider_ref
14663            .clone()
14664            .ok_or(SubscriberError::InvalidConfig(
14665                "Flashblocks preflight requires a stable provider ref",
14666            ))?;
14667        self.flashblocks_rpc_metrics.capability_requests = self
14668            .flashblocks_rpc_metrics
14669            .capability_requests
14670            .saturating_add(1);
14671        let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
14672            self.flashblocks_state_provider
14673                .as_ref()
14674                .unwrap_or(&self.provider)
14675        } else {
14676            &self.provider
14677        };
14678        let advertised_capabilities = capability_provider
14679            .client()
14680            .request::<_, serde_json::Value>("op_supportedCapabilities", ())
14681            .await
14682            .ok();
14683
14684        self.ensure_streams().await?;
14685        let pending_log_filters = self.log_stream_filters();
14686        if adapter == FlashblocksAdapter::PendingStatePolling
14687            && self.pending_receipt_requests_per_tick_capacity() == 0
14688        {
14689            return Err(SubscriberError::InvalidConfig(
14690                "Flashblocks RPC budget leaves no capacity for OP transaction receipts",
14691            ));
14692        }
14693        let (delivery, pending_log_subscriptions) = match adapter {
14694            FlashblocksAdapter::NativeSubscriptions => {
14695                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
14696                    return Err(SubscriberError::Unsupported(
14697                        "Base Flashblocks preflight requires pubsub",
14698                    ));
14699                }
14700                let pending_sources = self
14701                    .pubsub_stream_sources()
14702                    .into_iter()
14703                    .filter(|source| {
14704                        matches!(source, SubscriberStreamSource::BasePendingLog { .. })
14705                    })
14706                    .collect::<Vec<_>>();
14707                let AlloySubscriberState::Active(streams) = &self.state else {
14708                    return Err(SubscriberError::Provider(
14709                        "Flashblocks preflight subscriptions did not become active".to_owned(),
14710                    ));
14711                };
14712                if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
14713                    || pending_sources
14714                        .iter()
14715                        .any(|source| !streams.contains_source(source))
14716                {
14717                    return Err(SubscriberError::Provider(
14718                        "Base Flashblocks preflight did not retain both subscription lanes"
14719                            .to_owned(),
14720                    ));
14721                }
14722                (
14723                    FlashblocksDelivery::NativeSubscriptions,
14724                    pending_sources.len(),
14725                )
14726            }
14727            FlashblocksAdapter::PendingStatePolling => {
14728                if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
14729                    self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
14730                        .flashblocks_rpc_metrics
14731                        .provider_pair_chain_requests
14732                        .saturating_add(1);
14733                    let actual = state_provider
14734                        .get_chain_id()
14735                        .await
14736                        .map_err(provider_error)?;
14737                    if actual != expected_chain_id {
14738                        return Err(SubscriberError::ChainMismatch {
14739                            expected: expected_chain_id,
14740                            actual,
14741                        });
14742                    }
14743                }
14744                let AlloySubscriberState::Active(streams) = &self.state else {
14745                    return Err(SubscriberError::Provider(
14746                        "Flashblocks preflight streams did not become active".to_owned(),
14747                    ));
14748                };
14749                if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
14750                    return Err(SubscriberError::Provider(
14751                        "Optimism Flashblocks preflight did not retain its pending-state sampler"
14752                            .to_owned(),
14753                    ));
14754                }
14755                self.probe_pending_state(&pending_log_filters).await?;
14756                (FlashblocksDelivery::PendingStatePolling, 0)
14757            }
14758        };
14759        Ok(FlashblocksPreflight {
14760            chain_id,
14761            provider,
14762            delivery,
14763            pending_log_subscriptions,
14764            pending_log_filters: pending_log_filters.len(),
14765            advertised_capabilities,
14766        })
14767    }
14768
14769    /// Ingest one standardized update from an application-managed source.
14770    ///
14771    /// This method is synchronous and performs no provider I/O. The update is
14772    /// validated against the configured source identity, normalized through
14773    /// the same preconfirmation deduplication used by provider subscriptions,
14774    /// and queued for ordinary [`EventSubscriber`] delivery. Stale provider
14775    /// generations and stale invalidations cannot revoke newer speculative
14776    /// state. Indexed snapshots must begin at zero, advance exactly one index at
14777    /// a time, preserve their base identity and cumulative transaction prefix,
14778    /// and bind delta logs only to newly appended transactions.
14779    #[cfg(feature = "raw-flashblocks-json")]
14780    pub fn ingest_flashblock_update(
14781        &mut self,
14782        update: FlashblockUpdate,
14783    ) -> Result<(), SubscriberError> {
14784        validate_subscriber_config(&self.config)?;
14785        self.validate_flashblocks_setup()?;
14786        let configured =
14787            self.external_flashblocks_provider
14788                .as_ref()
14789                .ok_or(SubscriberError::InvalidConfig(
14790                    "standardized Flashblock updates require configure_external_flashblock_updates",
14791                ))?;
14792
14793        match update {
14794            FlashblockUpdate::Snapshot(batch) => {
14795                if batch.flashblock.provider.endpoint != configured.endpoint {
14796                    return Err(SubscriberError::Provider(
14797                        "external Flashblock update came from an unexpected provider endpoint"
14798                            .into(),
14799                    ));
14800                }
14801                if batch.flashblock.provider.generation < configured.generation
14802                    || self.latest_preconfirmation.as_ref().is_some_and(|latest| {
14803                        latest.provider.endpoint == batch.flashblock.provider.endpoint
14804                            && latest.provider.generation > batch.flashblock.provider.generation
14805                    })
14806                {
14807                    return Ok(());
14808                }
14809                if self
14810                    .rejected_external_flashblock_generation
14811                    .is_some_and(|rejected| batch.flashblock.provider.generation <= rejected)
14812                {
14813                    return Err(SubscriberError::Provider(
14814                        "external Flashblock provider generation was previously rejected".into(),
14815                    ));
14816                }
14817                validate_standard_flashblock_snapshot(&batch)?;
14818                if self.validate_external_flashblock_sequence(&batch)? {
14819                    return Ok(());
14820                }
14821                let required = self.pending_record_count().saturating_add(batch.logs.len());
14822                if required > self.config.max_pending_records {
14823                    self.invalidate_preconfirmation_snapshot();
14824                    self.last_external_flashblock_snapshot = None;
14825                    return Err(SubscriberError::ResourceExhausted(format!(
14826                        "external preconfirmation records require {required} pending records, above the configured limit of {}",
14827                        self.config.max_pending_records
14828                    )));
14829                }
14830                let accepted_snapshot = (*batch).clone();
14831                let FlashblockSnapshot { flashblock, logs } = *batch;
14832                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
14833                self.last_external_flashblock_snapshot = Some(accepted_snapshot);
14834                if let Some(provider) = self.external_flashblocks_provider.as_mut() {
14835                    provider.generation = provider.generation.max(flashblock.provider.generation);
14836                }
14837                if !logs.is_empty() {
14838                    self.enqueue_event(SubscriberEvent::PreconfirmedLogs { flashblock, logs });
14839                }
14840            }
14841            FlashblockUpdate::Invalidated(invalidation) => {
14842                if invalidation.provider.endpoint != configured.endpoint {
14843                    return Err(SubscriberError::Provider(
14844                        "external Flashblock invalidation came from an unexpected provider endpoint"
14845                            .into(),
14846                    ));
14847                }
14848                if self.latest_preconfirmation.as_ref().is_some_and(|latest| {
14849                    latest.provider == invalidation.provider
14850                        && latest.payload_id == Some(invalidation.payload_id)
14851                }) {
14852                    self.invalidate_preconfirmation_snapshot();
14853                    self.last_external_flashblock_snapshot = None;
14854                }
14855            }
14856        }
14857        Ok(())
14858    }
14859
14860    #[cfg(feature = "raw-flashblocks-json")]
14861    fn validate_external_flashblock_sequence(
14862        &self,
14863        snapshot: &FlashblockSnapshot,
14864    ) -> Result<bool, SubscriberError> {
14865        let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else {
14866            if snapshot.flashblock.index != Some(0) {
14867                return Err(SubscriberError::Provider(
14868                    "external Flashblock payload generation must begin at index zero".into(),
14869                ));
14870            }
14871            return Ok(false);
14872        };
14873
14874        if previous.flashblock.provider == snapshot.flashblock.provider
14875            && previous.flashblock.payload_id == snapshot.flashblock.payload_id
14876        {
14877            let previous_index = previous
14878                .flashblock
14879                .index
14880                .expect("validated indexed snapshot");
14881            let current_index = snapshot
14882                .flashblock
14883                .index
14884                .expect("validated indexed snapshot");
14885            if current_index == previous_index {
14886                if previous == snapshot {
14887                    return Ok(true);
14888                }
14889                return Err(SubscriberError::Provider(
14890                    "external Flashblock repeated the same index with conflicting content".into(),
14891                ));
14892            }
14893            if current_index < previous_index {
14894                return Err(SubscriberError::Provider(format!(
14895                    "external Flashblock index regressed from {previous_index} to {current_index}"
14896                )));
14897            }
14898            if current_index > previous_index.saturating_add(1) {
14899                return Err(SubscriberError::Provider(format!(
14900                    "external Flashblock index skipped from {previous_index} to {current_index}"
14901                )));
14902            }
14903            if current_index == previous_index.saturating_add(1)
14904                && !previous.flashblock.same_base_identity(&snapshot.flashblock)
14905            {
14906                return Err(SubscriberError::Provider(
14907                    "external Flashblock base identity changed within one payload generation"
14908                        .into(),
14909                ));
14910            }
14911            if current_index == previous_index.saturating_add(1)
14912                && !snapshot
14913                    .flashblock
14914                    .transaction_hashes
14915                    .starts_with(&previous.flashblock.transaction_hashes)
14916            {
14917                return Err(SubscriberError::Provider(
14918                    "external Flashblock cumulative transaction membership changed its prior prefix"
14919                        .into(),
14920                ));
14921            }
14922            let prior_transaction_count =
14923                u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX);
14924            if current_index == previous_index.saturating_add(1)
14925                && snapshot.logs.iter().any(|log| {
14926                    log.transaction_index
14927                        .is_some_and(|index| index < prior_transaction_count)
14928                })
14929            {
14930                return Err(SubscriberError::Provider(
14931                    "external Flashblock delta log does not belong to a newly appended transaction"
14932                        .into(),
14933                ));
14934            }
14935        } else if snapshot.flashblock.index != Some(0) {
14936            return Err(SubscriberError::Provider(
14937                "external Flashblock payload generation must begin at index zero".into(),
14938            ));
14939        }
14940        Ok(false)
14941    }
14942
14943    async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
14944        self.flashblocks_rpc_metrics.pending_block_requests = self
14945            .flashblocks_rpc_metrics
14946            .pending_block_requests
14947            .saturating_add(1);
14948        let pending = self
14949            .fetch_op_pending_block()
14950            .await
14951            .map_err(PendingFlashblockPollError::into_subscriber)?
14952            .ok_or_else(|| {
14953                SubscriberError::Provider(
14954                    "provider returned no pending block during Flashblocks preflight".into(),
14955                )
14956            })?;
14957        self.certify_op_pending_parent(&pending)
14958            .await
14959            .map_err(PendingFlashblockPollError::into_subscriber)?;
14960        for filter in filters {
14961            self.flashblocks_rpc_metrics.pending_log_requests = self
14962                .flashblocks_rpc_metrics
14963                .pending_log_requests
14964                .saturating_add(1);
14965            self.flashblocks_state_provider
14966                .as_ref()
14967                .unwrap_or(&self.provider)
14968                .get_logs(
14969                    &filter
14970                        .clone()
14971                        .from_block(BlockNumberOrTag::Latest)
14972                        .to_block(BlockNumberOrTag::Pending),
14973                )
14974                .await
14975                .map_err(provider_error)?;
14976        }
14977        self.flashblocks_rpc_metrics.pending_receipt_requests = self
14978            .flashblocks_rpc_metrics
14979            .pending_receipt_requests
14980            .saturating_add(1);
14981        let _: serde_json::Value = self
14982            .flashblocks_state_provider
14983            .as_ref()
14984            .unwrap_or(&self.provider)
14985            .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
14986            .await
14987            .map_err(provider_error)?;
14988        Ok(())
14989    }
14990
14991    async fn certify_op_pending_parent(
14992        &mut self,
14993        pending: &N::BlockResponse,
14994    ) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
14995        let pending_header = pending.header();
14996        let pending_number = pending_header.number();
14997        let parent_hash = pending_header.parent_hash();
14998        if pending_number == 0 || parent_hash.is_zero() {
14999            return Err(PendingFlashblockPollError::Integrity(
15000                SubscriberError::Provider(
15001                    "OP pending block omitted a certifiable canonical parent".into(),
15002                ),
15003            ));
15004        }
15005        self.flashblocks_rpc_metrics.canonical_head_requests = self
15006            .flashblocks_rpc_metrics
15007            .canonical_head_requests
15008            .saturating_add(1);
15009        let parent = self
15010            .flashblocks_state_provider
15011            .as_ref()
15012            .unwrap_or(&self.provider)
15013            .get_block_by_hash(parent_hash)
15014            .await
15015            .map_err(pending_flashblock_request_error)?
15016            .ok_or_else(|| {
15017                PendingFlashblockPollError::Request(SubscriberError::Provider(
15018                    "Flashblocks provider returned no exact OP pending parent block".into(),
15019                ))
15020            })?;
15021        let parent_header = parent.header();
15022        if parent_header.hash() != parent_hash
15023            || parent_header.number().checked_add(1) != Some(pending_number)
15024        {
15025            return Err(PendingFlashblockPollError::Integrity(
15026                SubscriberError::Provider(
15027                    "OP pending block does not extend its exact certified parent".into(),
15028                ),
15029            ));
15030        }
15031        Ok(parent_header.clone())
15032    }
15033
15034    async fn fetch_op_pending_block(
15035        &mut self,
15036    ) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
15037        let state_provider = self
15038            .flashblocks_state_provider
15039            .as_ref()
15040            .unwrap_or(&self.provider);
15041        let value: Option<serde_json::Value> = state_provider
15042            .raw_request(
15043                Cow::Borrowed("eth_getBlockByNumber"),
15044                (BlockNumberOrTag::Pending, true),
15045            )
15046            .await
15047            .map_err(pending_flashblock_request_error)?;
15048        value
15049            .map(normalize_op_pending_block::<N>)
15050            .transpose()
15051            .map_err(PendingFlashblockPollError::Integrity)
15052    }
15053
15054    /// Resolve the provider's chain identity once. The assignment happens only
15055    /// after a complete RPC response, so cancelling the future leaves the
15056    /// subscriber cleanly retryable.
15057    async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
15058        if let Some(chain_id) = self.chain_id {
15059            return Ok(chain_id);
15060        }
15061        let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
15062        self.chain_id = Some(chain_id);
15063        Ok(chain_id)
15064    }
15065
15066    fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
15067        if self.config.preconfirmations == PreconfirmationMode::Disabled {
15068            if self.uses_external_flashblock_updates() {
15069                return Err(SubscriberError::InvalidConfig(
15070                    "external Flashblock updates require preconfirmations to be preferred or required",
15071                ));
15072            }
15073            return Ok(());
15074        }
15075        if self.uses_external_flashblock_updates() {
15076            return Ok(());
15077        }
15078        if self.provider_ref.is_none() {
15079            return Err(SubscriberError::InvalidConfig(
15080                "Flashblocks require a stable provider ref from a pinned provider lease",
15081            ));
15082        }
15083        let Some(chain_id) = self.chain_id else {
15084            return Ok(());
15085        };
15086        match flashblocks_adapter(chain_id) {
15087            Some(FlashblocksAdapter::NativeSubscriptions)
15088                if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
15089                    && self.config.preconfirmations == PreconfirmationMode::Required =>
15090            {
15091                return Err(SubscriberError::Unsupported(
15092                    "Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
15093                ));
15094            }
15095            Some(FlashblocksAdapter::NativeSubscriptions) => {}
15096            Some(_) => {}
15097            None if self.config.preconfirmations == PreconfirmationMode::Required => {
15098                return Err(SubscriberError::Unsupported(
15099                    "Flashblocks are currently implemented for Base and OP chains",
15100                ));
15101            }
15102            None => {}
15103        }
15104        Ok(())
15105    }
15106
15107    /// Subscribe first, then catch an exact staged owner up through a verified
15108    /// canonical block.
15109    ///
15110    /// This compatibility wrapper delegates to
15111    /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a
15112    /// driver adopting several owners should call the bulk API once rather than
15113    /// invoking this method in a loop.
15114    ///
15115    /// # Errors
15116    ///
15117    /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a
15118    /// baseline, conflicts/regresses, provider certification or transport
15119    /// fails, returned logs are invalid, or subscriber resources are exhausted.
15120    pub async fn reconcile_interest_owner(
15121        &mut self,
15122        epoch: &SubscriberOwnerEpoch,
15123        through: BlockRef,
15124    ) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
15125    where
15126        P: Clone,
15127    {
15128        self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
15129            .await?
15130            .pop()
15131            .ok_or(SubscriberOwnerError::NotStaged)
15132    }
15133
15134    /// Subscribe first, then atomically catch staged owners up through one
15135    /// verified canonical block.
15136    ///
15137    /// All epochs are preflighted before provider I/O. Live streams are
15138    /// reconciled once, compatible provider filters are merged into bounded
15139    /// chunks, and every historical request shares one double target-header
15140    /// certification. Provider-filter supersets are routed back through each
15141    /// owner's exact interests, retaining owner-scoped delivery provenance.
15142    /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order.
15143    ///
15144    /// Live events are continuously drained while an independent provider
15145    /// clone performs catch-up. Fetched owner records and progress become
15146    /// visible only after every request and the final certification succeed. A
15147    /// failure leaves every target staged with its prior progress unchanged;
15148    /// live canonical delivery consumed during the attempt is preserved while
15149    /// excluding the failed target epochs from its staged-owner audience.
15150    ///
15151    /// # Errors
15152    ///
15153    /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a
15154    /// baseline, conflicts/regresses, provider certification or transport
15155    /// fails, returned logs are invalid, or subscriber resources are exhausted.
15156    /// Target progress remains unchanged on error.
15157    pub async fn reconcile_interest_owners(
15158        &mut self,
15159        epochs: &[SubscriberOwnerEpoch],
15160        through: BlockRef,
15161    ) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
15162    where
15163        P: Clone,
15164    {
15165        if epochs.is_empty() {
15166            return Ok(Vec::new());
15167        }
15168
15169        self.ensure_chain_id().await?;
15170
15171        let mut seen = HashSet::new();
15172        let mut plans = Vec::with_capacity(epochs.len());
15173        for epoch in epochs {
15174            if !seen.insert(epoch.clone()) {
15175                continue;
15176            }
15177            let entry = self
15178                .owned_interests
15179                .iter()
15180                .find(|entry| {
15181                    entry.epoch.as_ref() == Some(epoch)
15182                        && entry.state == SubscriberOwnerState::Staged
15183                })
15184                .ok_or(SubscriberOwnerError::NotStaged)?;
15185            let position = entry
15186                .progress
15187                .as_ref()
15188                .map(|progress| &progress.through)
15189                .or(entry.baseline.as_ref())
15190                .ok_or(SubscriberOwnerError::MissingBaseline)?;
15191            let baseline = position.number;
15192            if through.number < baseline {
15193                return Err(SubscriberOwnerError::ProgressRegression {
15194                    current: baseline,
15195                    target: through.number,
15196                });
15197            }
15198            let from_block = baseline
15199                .checked_add(1)
15200                .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
15201            if through.number == baseline && through.hash != position.hash {
15202                return Err(SubscriberOwnerError::ProgressConflict {
15203                    number: baseline,
15204                    current_hash: position.hash,
15205                    target_hash: through.hash,
15206                });
15207            }
15208            if through.number == from_block
15209                && through
15210                    .parent_hash
15211                    .is_some_and(|parent| parent != position.hash)
15212            {
15213                return Err(SubscriberOwnerError::ProgressConflict {
15214                    number: baseline,
15215                    current_hash: position.hash,
15216                    target_hash: through.parent_hash.expect("checked as present above"),
15217                });
15218            }
15219            if entry
15220                .interests
15221                .iter()
15222                .any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
15223            {
15224                return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
15225            }
15226            plans.push(SubscriberOwnerReconcilePlan {
15227                epoch: epoch.clone(),
15228                interests: entry.interests.clone(),
15229                retained: *position,
15230                from_block,
15231            });
15232        }
15233
15234        // The ordering is intentional and part of the public continuity
15235        // contract: connect first, then fetch the bounded historical window.
15236        self.ensure_streams().await?;
15237        let provider = self.provider.clone();
15238        let filters = merged_owner_reconcile_filters(&plans, through.number);
15239        let retained = plans.iter().map(|plan| plan.retained).collect();
15240        let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
15241        let fetch = fetch_owner_catchup::<P, N>(
15242            provider,
15243            filters,
15244            retained,
15245            through,
15246            SubscriberOwnerCatchupOptions {
15247                target_preverified: false,
15248                max_logs: self.config.max_pending_records,
15249                max_log_bytes: self.config.max_backfill_log_bytes,
15250                max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
15251            },
15252        );
15253        let SubscriberOwnerCatchup { logs, certified } =
15254            self.drive_reconcile_fetch(fetch, &target_epochs).await?;
15255
15256        let records = logs
15257            .into_iter()
15258            .map(|log| log_input_record(log, InputSource::Backfill))
15259            .collect();
15260        let mut routed_records = Vec::new();
15261        for record in dedupe_records(sort_records(records)).map_err(|error| {
15262            SubscriberError::InvalidBackfill(format!(
15263                "conflicting duplicate owner catch-up record: {error}"
15264            ))
15265        })? {
15266            let block_number = match &record.input {
15267                ReactiveInput::Log(log) => log
15268                    .block_number
15269                    .expect("bulk catch-up logs were validated before commit"),
15270                _ => unreachable!("bulk owner catch-up contains log records only"),
15271            };
15272            let owners: Vec<SubscriberOwnerEpoch> = plans
15273                .iter()
15274                .filter(|plan| block_number >= plan.from_block)
15275                .filter(|plan| {
15276                    plan.interests
15277                        .iter()
15278                        .any(|interest| interest_matches(interest, &record.input))
15279                })
15280                .map(|plan| plan.epoch.clone())
15281                .collect();
15282            if !owners.is_empty() {
15283                routed_records.push((record, owners));
15284            }
15285        }
15286        self.ensure_pending_record_capacity(
15287            routed_records.len(),
15288            "owner reconciliation historical records",
15289        )?;
15290
15291        // Nothing provider-derived becomes authoritative until every record is
15292        // known to fit. In particular, preserve queued retry state and owner
15293        // progress when the bounded delivery queue cannot accept the catch-up.
15294        self.pending_backfills.retain(|queued| {
15295            queued
15296                .epoch
15297                .as_ref()
15298                .is_none_or(|epoch| !target_epochs.contains(epoch))
15299        });
15300        for (record, owners) in routed_records {
15301            self.enqueue_owner_record_for_owners_unmerged(record, owners);
15302        }
15303        self.promote_reconcile_owner_records(&target_epochs);
15304        self.seed_reconciled_filter_anchors(&plans, certified.number);
15305
15306        let stream_revision = self.stream_revision;
15307        let mut progress = Vec::with_capacity(plans.len());
15308        for plan in plans {
15309            let item = SubscriberOwnerProgress {
15310                owner: plan.epoch.clone(),
15311                through: certified,
15312            };
15313            let entry = self
15314                .owned_interests
15315                .iter_mut()
15316                .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
15317                .expect("bulk reconcile holds exclusive access after epoch preflight");
15318            entry.progress = Some(item.clone());
15319            entry.progress_stream_revision = Some(stream_revision);
15320            progress.push(item);
15321        }
15322        Ok(progress)
15323    }
15324
15325    async fn drive_reconcile_fetch<T, F>(
15326        &mut self,
15327        fetch: F,
15328        target_epochs: &HashSet<SubscriberOwnerEpoch>,
15329    ) -> Result<T, SubscriberOwnerError>
15330    where
15331        F: Future<Output = Result<T, SubscriberOwnerError>>,
15332    {
15333        if !matches!(&self.state, AlloySubscriberState::Active(_)) {
15334            return fetch.await;
15335        }
15336        let mut fetch = Box::pin(fetch);
15337        loop {
15338            let event = {
15339                let live = Box::pin(self.next_event());
15340                match select(fetch, live).await {
15341                    Either::Left((result, pending_live)) => {
15342                        drop(pending_live);
15343                        return result;
15344                    }
15345                    Either::Right((event, pending_fetch)) => {
15346                        fetch = pending_fetch;
15347                        event
15348                    }
15349                }
15350            };
15351            let event = event?.ok_or_else(|| {
15352                SubscriberError::Provider(
15353                    "Alloy subscriber streams ended during owner reconcile".to_owned(),
15354                )
15355            })?;
15356            self.buffer_reconcile_event_for_owners(&event, target_epochs);
15357            self.enqueue_event_excluding_owners(event, target_epochs);
15358            self.check_resource_error()?;
15359        }
15360    }
15361
15362    /// Poll one driver control future with priority over the next scoped batch.
15363    ///
15364    /// This is the supported control-interleaving primitive for a subscriber
15365    /// driver. `control` is borrowed rather than consumed, so a batch win leaves
15366    /// the caller's pending control future alive. When control wins, the
15367    /// in-progress subscriber poll is cancelled at a documented safe boundary:
15368    /// queued records are removed only when a complete batch is returned,
15369    /// successful backfill steps are committed before the next await, provider
15370    /// streams created but not installed are dropped, and installed streams
15371    /// remain owned by the subscriber for the next call.
15372    ///
15373    /// The control future is polled first. Therefore a ready shutdown/removal
15374    /// command cannot starve behind a continuously ready subscriber queue.
15375    ///
15376    /// # Errors
15377    ///
15378    /// Returns [`SubscriberError`] when the subscriber poll encounters a
15379    /// transport, continuity, decoding, configuration, or resource failure.
15380    pub async fn next_scoped_batch_or<C, F>(
15381        &mut self,
15382        control: Pin<&mut F>,
15383    ) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
15384    where
15385        C: Send,
15386        F: Future<Output = C> + Send,
15387    {
15388        let batch = self.next_scoped_batch();
15389        match select(control, batch).await {
15390            Either::Left((control, pending_batch)) => {
15391                drop(pending_batch);
15392                Ok(SubscriberDriverPoll::Control(control))
15393            }
15394            Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
15395        }
15396    }
15397
15398    /// Return the next subscriber batch while retaining staged-owner delivery
15399    /// provenance captured at enqueue time.
15400    ///
15401    /// Transaction-aware drivers must use this method. The compatibility
15402    /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps
15403    /// its historical behavior for existing callers.
15404    ///
15405    /// For command interleaving, prefer
15406    /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the
15407    /// cancellation-safety invariants of this poll and prioritizes ready control.
15408    pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
15409        Box::pin(async {
15410            self.check_resource_error()?;
15411            if self.chain_id.is_none()
15412                && (!self.pending_records.is_empty()
15413                    || !self.pending_chain_controls.is_empty()
15414                    || !self.pending_backfills.is_empty()
15415                    || !self.interests.is_empty())
15416            {
15417                self.ensure_chain_id().await?;
15418            }
15419            if let Some(batch) = self.drain_next_scoped_batch() {
15420                return Ok(Some(batch));
15421            }
15422
15423            // Subscribe/adopt the complete desired topology before resolving
15424            // any queued historical upper bound. Live streams therefore own
15425            // every event that can arrive while the bounded backfill is in
15426            // flight, including the coordinated registration window.
15427            self.ensure_streams().await?;
15428            self.check_resource_error()?;
15429            if let Some(batch) = self.drain_next_scoped_batch() {
15430                return Ok(Some(batch));
15431            }
15432
15433            self.drain_pending_backfills().await?;
15434            self.check_resource_error()?;
15435            if let Some(batch) = self.drain_next_scoped_batch() {
15436                return Ok(Some(batch));
15437            }
15438
15439            if self.interests.is_empty() {
15440                return Ok(None);
15441            }
15442
15443            loop {
15444                let Some(event) = self.next_event().await? else {
15445                    return Ok(None);
15446                };
15447
15448                self.enqueue_event(event);
15449                self.check_resource_error()?;
15450                if let Some(batch) = self.drain_next_scoped_batch() {
15451                    return Ok(Some(batch));
15452                }
15453            }
15454        })
15455    }
15456
15457    /// Bring live streams in line with the current interest set.
15458    ///
15459    /// Runs incrementally: the desired-vs-live diff only happens when interest
15460    /// bookkeeping changed since the last successful pass (`sources_dirty`), so
15461    /// steady-state polling costs nothing here. Missing sources are connected,
15462    /// sources for retired filters are dropped (dropping an Alloy subscription
15463    /// unsubscribes provider-side), and unrelated live streams — with their
15464    /// delivery and anchor state — are left untouched.
15465    ///
15466    /// A newly connected log source whose filter already has a delivery anchor
15467    /// is caught up from that anchor immediately after subscribing (the same
15468    /// subscribe-then-backfill order the reconnect path uses). Together with
15469    /// anchor seeding in [`Self::drain_pending_backfills`], that closes the
15470    /// window between an adoption backfill and live stream start.
15471    async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
15472        if !self.sources_dirty {
15473            return Ok(());
15474        }
15475        // An interest-less subscriber never touches the provider
15476        // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify
15477        // the empty desired topology as clean so a deliberately empty staged
15478        // epoch can reconcile and activate instead of remaining dirty forever.
15479        if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
15480            self.bump_stream_revision();
15481            self.sources_dirty = false;
15482            return Ok(());
15483        }
15484
15485        let desired = self.stream_sources()?;
15486        let missing: Vec<SubscriberStreamSource> = match &self.state {
15487            AlloySubscriberState::Active(streams) => desired
15488                .iter()
15489                .filter(|source| !streams.contains_source(source))
15490                .cloned()
15491                .collect(),
15492            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
15493        };
15494
15495        for source in missing {
15496            let stream = match self.connect_source_stream(source.clone()).await {
15497                Ok(stream) => stream,
15498                Err(error)
15499                    if source.is_flashblocks()
15500                        && self.config.preconfirmations == PreconfirmationMode::Preferred =>
15501                {
15502                    tracing::warn!(
15503                        stream = source.label(),
15504                        error = %error,
15505                        "Flashblocks source unavailable; canonical delivery remains active"
15506                    );
15507                    if self.config.reconnect.enabled {
15508                        self.schedule_flashblock_reconnect(
15509                            source,
15510                            self.config.reconnect.retry_delay,
15511                        );
15512                    }
15513                    continue;
15514                }
15515                Err(error) => return Err(error),
15516            };
15517            // Publish each successful connection before any later await. If a
15518            // second connection or anchored catch-up fails/cancels, this stream
15519            // remains live and the next reconcile skips reconnecting it.
15520            self.install_source_stream(source.clone(), stream);
15521            if self.source_requires_backfill(&source) {
15522                self.queue_source_backfill(source);
15523            }
15524        }
15525
15526        while let Some(source) = self.pending_source_backfills.front().cloned() {
15527            let desired_and_live = desired.iter().any(|item| item.same_key(&source))
15528                && matches!(
15529                    &self.state,
15530                    AlloySubscriberState::Active(streams) if streams.contains_source(&source)
15531                );
15532            if !desired_and_live {
15533                self.pending_source_backfills.pop_front();
15534                continue;
15535            }
15536
15537            // Anchored catch-up for a source with a known delivery watermark
15538            // (seeded by a drained adoption backfill, or inherited from a
15539            // filter shape that was live before): subscribe first, then fetch
15540            // the gap, so nothing lands between the two. Pop only after the
15541            // request succeeds; errors and cancellation retain retry intent.
15542            let event = self.backfill_reconnected_source(&source).await?;
15543            self.pending_source_backfills.pop_front();
15544            if let Some(event) = event {
15545                self.enqueue_event(event);
15546            }
15547        }
15548
15549        if let AlloySubscriberState::Active(streams) = &mut self.state {
15550            streams.retain_sources(&desired);
15551            if streams.is_empty() {
15552                self.state = AlloySubscriberState::Empty;
15553            }
15554        }
15555
15556        self.bump_stream_revision();
15557        self.sources_dirty = false;
15558        self.retire_unreferenced_filters();
15559        Ok(())
15560    }
15561
15562    fn install_source_stream(
15563        &mut self,
15564        source: SubscriberStreamSource,
15565        stream: BoxStream<'static, SubscriberEvent<N>>,
15566    ) {
15567        match &mut self.state {
15568            AlloySubscriberState::Active(streams) => {
15569                if streams.contains_source(&source) {
15570                    return;
15571                }
15572                streams.push(source, stream);
15573            }
15574            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
15575                let mut streams = SubscriberStreams::new();
15576                streams.push(source, stream);
15577                self.state = AlloySubscriberState::Active(streams);
15578            }
15579        }
15580        // A partially completed reconcile is still a topology change. Advance
15581        // the revision now rather than only at the final clean boundary.
15582        self.bump_stream_revision();
15583    }
15584
15585    fn schedule_flashblock_reconnect(
15586        &mut self,
15587        source: SubscriberStreamSource,
15588        first_delay: Duration,
15589    ) {
15590        if self
15591            .pending_flashblock_reconnect_sources
15592            .iter()
15593            .any(|pending| pending.same_key(&source))
15594        {
15595            return;
15596        }
15597        self.pending_flashblock_reconnect_sources
15598            .push(source.clone());
15599        self.pending_flashblock_reconnects
15600            .push(flashblock_reconnect_future(
15601                self.provider.root().clone(),
15602                source,
15603                self.config.max_batch_size,
15604                self.config.reconnect.clone(),
15605                first_delay,
15606                self.config.flashblock_poll_interval,
15607            ));
15608    }
15609
15610    fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
15611        if !self.config.reconnect.enabled {
15612            return;
15613        }
15614        self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
15615    }
15616
15617    fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
15618        matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
15619            if self.last_seen_log_blocks.contains_key(id))
15620    }
15621
15622    fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
15623        if !self
15624            .pending_source_backfills
15625            .iter()
15626            .any(|pending| pending.same_key(&source))
15627        {
15628            self.pending_source_backfills.push_back(source);
15629        }
15630    }
15631
15632    /// Fetch queued adoption/continuity backfills, oldest first.
15633    ///
15634    /// An entry is consumed only after its `get_logs` fetch succeeds — a
15635    /// transient RPC failure surfaces the error and leaves the entry queued for
15636    /// the next poll, so a flaky request cannot silently discard the missed
15637    /// window the backfill exists to close. Open-ended backfills resolve their
15638    /// upper bound to the provider's current head before fetching, and every
15639    /// drained backfill advances the filter's delivery anchor to that bound —
15640    /// even a zero-log window — so the filter is reconnect-protected from then
15641    /// on. Draining pauses as soon as records are ready for delivery; remaining
15642    /// entries stay queued.
15643    async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
15644        while let Some(queued) = self.pending_backfills.front() {
15645            // Owner was removed while its backfill was queued.
15646            let epoch = queued.epoch.clone();
15647            let owner = queued.owner.clone();
15648            let owner_exists = match (&epoch, &owner) {
15649                (Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
15650                (None, Some(owner)) => self.owner_interests(owner).is_some(),
15651                (None, None) => true,
15652            };
15653            if !owner_exists {
15654                self.pending_backfills.pop_front();
15655                continue;
15656            }
15657            let filters = queued.filters.clone();
15658            let backfill = queued.backfill;
15659
15660            let to_block = match backfill.end_block() {
15661                Some(to_block) => to_block,
15662                None => self
15663                    .provider
15664                    .get_block_number()
15665                    .await
15666                    .map_err(provider_error)?,
15667            };
15668            if to_block < backfill.start_block() {
15669                // An exclusive post-baseline range can be empty when the
15670                // provider is still exactly at the retained head. Consume the
15671                // work only after validating that head and seed the filter at
15672                // the proven baseline so reconnect catch-up starts at C + 1.
15673                let certified = if let Some(retained) = backfill.retained_anchor() {
15674                    let actual =
15675                        fetch_provider_block_ref::<P, N>(&self.provider, retained.number).await?;
15676                    if !block_ref_satisfies_expected(&actual, retained) {
15677                        return Err(SubscriberError::InvalidBackfill(format!(
15678                            "retained anchor {}:{:?} conflicts with provider block {}:{:?}",
15679                            retained.number, retained.hash, actual.number, actual.hash
15680                        )));
15681                    }
15682                    if to_block < retained.number {
15683                        return Err(SubscriberError::InvalidBackfill(format!(
15684                            "backfill upper bound {to_block} precedes retained anchor {}",
15685                            retained.number
15686                        )));
15687                    }
15688                    Some(actual)
15689                } else {
15690                    None
15691                };
15692                self.pending_backfills.pop_front();
15693                for filter in &filters {
15694                    let source_id = self.log_source_id(filter);
15695                    if let Some(certified) = certified {
15696                        self.last_seen_log_blocks
15697                            .entry(source_id)
15698                            .and_modify(|anchor| *anchor = (*anchor).max(certified.number))
15699                            .or_insert(certified.number);
15700                    }
15701                }
15702                if owner.is_none()
15703                    && let Some(certified) = certified
15704                {
15705                    self.pending_chain_controls
15706                        .push_back(global_backfill_barrier(backfill, certified));
15707                }
15708                if !self.pending_chain_controls.is_empty() {
15709                    break;
15710                }
15711                continue;
15712            }
15713
15714            let through = fetch_provider_block_ref::<P, N>(&self.provider, to_block).await?;
15715            let request_filters =
15716                merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
15717            let retained = backfill.retained_anchor().copied().into_iter().collect();
15718            let SubscriberOwnerCatchup {
15719                mut logs,
15720                certified,
15721            } = fetch_owner_catchup::<&P, N>(
15722                &self.provider,
15723                request_filters,
15724                retained,
15725                through,
15726                SubscriberOwnerCatchupOptions {
15727                    target_preverified: true,
15728                    max_logs: self.config.max_pending_records,
15729                    max_log_bytes: self.config.max_backfill_log_bytes,
15730                    max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
15731                },
15732            )
15733            .await
15734            .map_err(lazy_backfill_error)?;
15735            logs.sort_by_key(|log| {
15736                (
15737                    log.block_number.unwrap_or_default(),
15738                    log.transaction_index.unwrap_or_default(),
15739                    log.log_index.unwrap_or_default(),
15740                )
15741            });
15742            logs.dedup();
15743            self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
15744
15745            // Fetch succeeded: consume the entry, deliver, and advance the
15746            // complete filter group through one globally ordered window.
15747            self.pending_backfills.pop_front();
15748            if let Some(epoch) = epoch.as_ref() {
15749                self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
15750            } else if let Some(owner) = owner.as_ref() {
15751                self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
15752            } else {
15753                self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
15754                self.pending_chain_controls
15755                    .push_back(global_backfill_barrier(backfill, certified));
15756            }
15757            for filter in &filters {
15758                let source_id = self.log_source_id(filter);
15759                let anchor = self
15760                    .last_seen_log_blocks
15761                    .entry(source_id)
15762                    .or_insert(certified.number);
15763                *anchor = (*anchor).max(certified.number);
15764            }
15765
15766            if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
15767                break;
15768            }
15769        }
15770        Ok(())
15771    }
15772
15773    fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
15774        let sources = match resolve_subscriber_transport(self.mode)? {
15775            SubscriberTransport::PubSub => self.pubsub_stream_sources(),
15776            SubscriberTransport::Polling => self.polling_stream_sources(),
15777        };
15778        #[cfg(feature = "raw-flashblocks-json")]
15779        let sources = {
15780            let mut sources = sources;
15781            if self.external_flashblock_update_channel_opened {
15782                sources.push(SubscriberStreamSource::ExternalFlashblockUpdates);
15783            }
15784            sources
15785        };
15786        Ok(sources)
15787    }
15788
15789    fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
15790        let mut sources = Vec::new();
15791        let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
15792
15793        for filter in self.log_stream_filters() {
15794            let id = self.log_source_id(&filter);
15795            if let Some(anchor) = inherited_anchor {
15796                self.last_seen_log_blocks.entry(id).or_insert(anchor);
15797            }
15798            sources.push(SubscriberStreamSource::PubSubLog { id, filter });
15799        }
15800
15801        if needs_pending_hash_stream(&self.interests) {
15802            sources.push(SubscriberStreamSource::PubSubPendingHashes);
15803        }
15804
15805        if needs_header_block_stream(&self.interests) {
15806            if !self.uses_external_flashblock_updates()
15807                && self.config.preconfirmations != PreconfirmationMode::Disabled
15808                && self.chain_id.and_then(flashblocks_adapter).is_some()
15809            {
15810                sources.push(SubscriberStreamSource::CanonicalHeadPolling);
15811            } else {
15812                sources.push(SubscriberStreamSource::PubSubBlockHeaders);
15813            }
15814        }
15815
15816        if self.config.preconfirmations != PreconfirmationMode::Disabled
15817            && !self.uses_external_flashblock_updates()
15818        {
15819            match self.chain_id.and_then(flashblocks_adapter) {
15820                Some(FlashblocksAdapter::NativeSubscriptions) => {
15821                    sources.push(SubscriberStreamSource::BaseFlashblocks);
15822                    for filter in self.log_stream_filters() {
15823                        let id = self.log_source_id(&filter);
15824                        sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
15825                    }
15826                }
15827                Some(FlashblocksAdapter::PendingStatePolling) => {
15828                    sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15829                }
15830                None => {}
15831            }
15832        }
15833
15834        sources
15835    }
15836
15837    fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
15838        let mut sources = Vec::new();
15839
15840        for filter in self.log_stream_filters() {
15841            sources.push(SubscriberStreamSource::PollingLog { filter });
15842        }
15843
15844        if needs_pending_hash_stream(&self.interests) {
15845            sources.push(SubscriberStreamSource::PollingPendingHashes);
15846        }
15847
15848        if self.config.preconfirmations != PreconfirmationMode::Disabled
15849            && !self.uses_external_flashblock_updates()
15850            && self.chain_id.and_then(flashblocks_adapter)
15851                == Some(FlashblocksAdapter::PendingStatePolling)
15852        {
15853            sources.push(SubscriberStreamSource::OpPendingFlashblocks);
15854        }
15855
15856        sources
15857    }
15858
15859    fn log_source_id(&mut self, filter: &Filter) -> usize {
15860        if let Some(id) = self.log_source_ids.get(filter) {
15861            return *id;
15862        }
15863
15864        let id = self.next_log_source_id;
15865        self.next_log_source_id = self.next_log_source_id.saturating_add(1);
15866        self.log_source_ids.insert(filter.clone(), id);
15867        id
15868    }
15869
15870    async fn connect_source_stream(
15871        &mut self,
15872        source: SubscriberStreamSource,
15873    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15874        match source {
15875            SubscriberStreamSource::PubSubLog { id, filter } => {
15876                self.connect_pubsub_log_stream(id, filter).await
15877            }
15878            SubscriberStreamSource::BasePendingLog { id, filter } => {
15879                self.connect_base_pending_log_stream(id, filter).await
15880            }
15881            SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
15882            SubscriberStreamSource::OpPendingFlashblocks => {
15883                self.connect_op_flashblock_tick_stream()
15884            }
15885            SubscriberStreamSource::CanonicalHeadPolling => {
15886                self.connect_canonical_head_tick_stream()
15887            }
15888            SubscriberStreamSource::PubSubPendingHashes => {
15889                self.connect_pubsub_pending_hash_stream().await
15890            }
15891            SubscriberStreamSource::PubSubBlockHeaders => {
15892                self.connect_pubsub_block_header_stream().await
15893            }
15894            SubscriberStreamSource::PollingLog { filter } => {
15895                self.connect_polling_log_stream(filter).await
15896            }
15897            SubscriberStreamSource::PollingPendingHashes => {
15898                self.connect_polling_pending_hash_stream().await
15899            }
15900            #[cfg(feature = "raw-flashblocks-json")]
15901            SubscriberStreamSource::ExternalFlashblockUpdates => {
15902                let receiver = self.external_flashblock_updates.take().ok_or_else(|| {
15903                    SubscriberError::Provider(
15904                        "external Flashblock update channel receiver is unavailable".into(),
15905                    )
15906                })?;
15907                let updates = stream::unfold(receiver, |mut receiver| async move {
15908                    receiver
15909                        .recv()
15910                        .await
15911                        .map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver))
15912                });
15913                Ok(stream_with_termination(
15914                    updates,
15915                    SubscriberStreamSource::ExternalFlashblockUpdates,
15916                ))
15917            }
15918        }
15919    }
15920
15921    async fn connect_pubsub_log_stream(
15922        &mut self,
15923        id: usize,
15924        filter: Filter,
15925    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15926        #[cfg(feature = "reactive-ws")]
15927        {
15928            let source = SubscriberStreamSource::PubSubLog {
15929                id,
15930                filter: filter.clone(),
15931            };
15932            let stream = self
15933                .provider
15934                .subscribe_logs(&filter)
15935                .channel_size(self.config.max_batch_size.max(1))
15936                .await
15937                .map_err(provider_error)?
15938                .into_stream()
15939                .map(move |log| SubscriberEvent::Log { source_id: id, log });
15940            Ok(stream_with_termination(stream, source))
15941        }
15942
15943        #[cfg(not(feature = "reactive-ws"))]
15944        {
15945            let _ = (id, filter);
15946            Err(SubscriberError::Unsupported(
15947                "AlloySubscriber pubsub mode requires the reactive-ws feature",
15948            ))
15949        }
15950    }
15951
15952    async fn connect_base_pending_log_stream(
15953        &mut self,
15954        id: usize,
15955        filter: Filter,
15956    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15957        #[cfg(feature = "reactive-ws")]
15958        {
15959            let source = SubscriberStreamSource::BasePendingLog {
15960                id,
15961                filter: filter.clone(),
15962            };
15963            let params = base_pending_log_filter(&filter)?;
15964            let stream = self
15965                .provider
15966                .subscribe::<_, Log>(("pendingLogs", params))
15967                .channel_size(self.config.max_batch_size.max(1))
15968                .await
15969                .map_err(provider_error)?
15970                .into_stream()
15971                .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
15972            Ok(stream_with_termination(stream, source))
15973        }
15974
15975        #[cfg(not(feature = "reactive-ws"))]
15976        {
15977            let _ = (id, filter);
15978            Err(SubscriberError::Unsupported(
15979                "Base Flashblocks require the reactive-ws feature",
15980            ))
15981        }
15982    }
15983
15984    async fn connect_base_flashblock_stream(
15985        &mut self,
15986    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
15987        #[cfg(feature = "reactive-ws")]
15988        {
15989            let stream = self
15990                .provider
15991                .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
15992                .channel_size(self.config.max_batch_size.max(1))
15993                .await
15994                .map_err(provider_error)?
15995                .into_stream()
15996                .map(SubscriberEvent::BaseFlashblock);
15997            Ok(stream_with_termination(
15998                stream,
15999                SubscriberStreamSource::BaseFlashblocks,
16000            ))
16001        }
16002
16003        #[cfg(not(feature = "reactive-ws"))]
16004        {
16005            Err(SubscriberError::Unsupported(
16006                "Base Flashblocks require the reactive-ws feature",
16007            ))
16008        }
16009    }
16010
16011    fn connect_canonical_head_tick_stream(
16012        &self,
16013    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16014        let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
16015        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
16016        let stream = stream::unfold(interval, |mut interval| async move {
16017            interval.tick().await;
16018            Some((SubscriberEvent::CanonicalHeadTick, interval))
16019        });
16020        Ok(stream_with_termination(
16021            stream,
16022            SubscriberStreamSource::CanonicalHeadPolling,
16023        ))
16024    }
16025
16026    fn connect_op_flashblock_tick_stream(
16027        &self,
16028    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16029        let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
16030        let mut interval =
16031            tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
16032        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
16033        let stream = stream::unfold(interval, |mut interval| async move {
16034            interval.tick().await;
16035            Some((SubscriberEvent::OpFlashblockTick, interval))
16036        });
16037        Ok(stream_with_termination(
16038            stream,
16039            SubscriberStreamSource::OpPendingFlashblocks,
16040        ))
16041    }
16042
16043    async fn connect_pubsub_pending_hash_stream(
16044        &mut self,
16045    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16046        #[cfg(feature = "reactive-ws")]
16047        {
16048            let stream = self
16049                .provider
16050                .subscribe_pending_transactions()
16051                .channel_size(self.config.max_batch_size.max(1))
16052                .await
16053                .map_err(provider_error)?
16054                .into_stream()
16055                .map(SubscriberEvent::PendingHash);
16056            Ok(stream_with_termination(
16057                stream,
16058                SubscriberStreamSource::PubSubPendingHashes,
16059            ))
16060        }
16061
16062        #[cfg(not(feature = "reactive-ws"))]
16063        {
16064            Err(SubscriberError::Unsupported(
16065                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16066            ))
16067        }
16068    }
16069
16070    async fn connect_pubsub_block_header_stream(
16071        &mut self,
16072    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16073        #[cfg(feature = "reactive-ws")]
16074        {
16075            let stream = self
16076                .provider
16077                .subscribe_blocks()
16078                .channel_size(self.config.max_batch_size.max(1))
16079                .await
16080                .map_err(provider_error)?
16081                .into_stream()
16082                .map(SubscriberEvent::BlockHeader);
16083            Ok(stream_with_termination(
16084                stream,
16085                SubscriberStreamSource::PubSubBlockHeaders,
16086            ))
16087        }
16088
16089        #[cfg(not(feature = "reactive-ws"))]
16090        {
16091            Err(SubscriberError::Unsupported(
16092                "AlloySubscriber pubsub mode requires the reactive-ws feature",
16093            ))
16094        }
16095    }
16096
16097    async fn connect_polling_log_stream(
16098        &mut self,
16099        filter: Filter,
16100    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16101        #[cfg(feature = "reactive-polling")]
16102        {
16103            let source = SubscriberStreamSource::PollingLog {
16104                filter: filter.clone(),
16105            };
16106            let stream = self
16107                .provider
16108                .watch_logs(&filter)
16109                .await
16110                .map_err(provider_error)?
16111                .with_channel_size(self.config.max_batch_size.max(1))
16112                .into_stream()
16113                .map(SubscriberEvent::Logs);
16114            Ok(stream_with_termination(stream, source))
16115        }
16116
16117        #[cfg(not(feature = "reactive-polling"))]
16118        {
16119            let _ = filter;
16120            Err(SubscriberError::Unsupported(
16121                "AlloySubscriber polling mode requires the reactive-polling feature",
16122            ))
16123        }
16124    }
16125
16126    async fn connect_polling_pending_hash_stream(
16127        &mut self,
16128    ) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
16129        #[cfg(feature = "reactive-polling")]
16130        {
16131            let stream = self
16132                .provider
16133                .watch_pending_transactions()
16134                .await
16135                .map_err(provider_error)?
16136                .with_channel_size(self.config.max_batch_size.max(1))
16137                .into_stream()
16138                .map(SubscriberEvent::PendingHashes);
16139            Ok(stream_with_termination(
16140                stream,
16141                SubscriberStreamSource::PollingPendingHashes,
16142            ))
16143        }
16144
16145        #[cfg(not(feature = "reactive-polling"))]
16146        {
16147            Err(SubscriberError::Unsupported(
16148                "AlloySubscriber polling mode requires the reactive-polling feature",
16149            ))
16150        }
16151    }
16152
16153    async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16154        loop {
16155            let ready = match &mut self.state {
16156                AlloySubscriberState::Active(streams)
16157                    if !self.pending_flashblock_reconnects.is_empty() =>
16158                {
16159                    let stream_event = Box::pin(streams.next());
16160                    let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
16161                    match select(reconnect, stream_event).await {
16162                        Either::Left((reconnect, pending_event)) => {
16163                            drop(pending_event);
16164                            let Some((source, result)) = reconnect else {
16165                                continue;
16166                            };
16167                            SubscriberReady::FlashblockReconnect(source, result)
16168                        }
16169                        Either::Right((event, pending_reconnect)) => {
16170                            drop(pending_reconnect);
16171                            SubscriberReady::Event(event)
16172                        }
16173                    }
16174                }
16175                AlloySubscriberState::Active(streams) => {
16176                    SubscriberReady::Event(streams.next().await)
16177                }
16178                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
16179                    if !self.pending_flashblock_reconnects.is_empty() =>
16180                {
16181                    let Some((source, result)) = self.pending_flashblock_reconnects.next().await
16182                    else {
16183                        continue;
16184                    };
16185                    SubscriberReady::FlashblockReconnect(source, result)
16186                }
16187                AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
16188                    return Ok(None);
16189                }
16190            };
16191
16192            let event = match ready {
16193                SubscriberReady::Event(event) => event,
16194                SubscriberReady::FlashblockReconnect(source, result) => {
16195                    self.pending_flashblock_reconnect_sources
16196                        .retain(|pending| !pending.same_key(&source));
16197                    match result {
16198                        Ok(stream) => {
16199                            self.install_source_stream(source, stream);
16200                        }
16201                        Err(error)
16202                            if self.config.preconfirmations == PreconfirmationMode::Preferred =>
16203                        {
16204                            tracing::warn!(
16205                                stream = source.label(),
16206                                error = %error,
16207                                "Flashblocks reconnect window exhausted; canonical delivery remains active"
16208                            );
16209                            self.reschedule_preferred_flashblock(source);
16210                        }
16211                        Err(error) => return Err(error),
16212                    }
16213                    continue;
16214                }
16215            };
16216
16217            let Some(event) = event else {
16218                return Err(SubscriberError::Provider(
16219                    "Alloy subscriber streams terminated before the subscriber was stopped"
16220                        .to_owned(),
16221                ));
16222            };
16223
16224            match event {
16225                SubscriberEvent::StreamTerminated(source) => {
16226                    if source.is_external_flashblocks() {
16227                        #[cfg(feature = "raw-flashblocks-json")]
16228                        {
16229                            self.external_flashblock_update_channel_opened = false;
16230                        }
16231                        if let AlloySubscriberState::Active(streams) = &mut self.state {
16232                            streams
16233                                .entries
16234                                .retain(|entry| !entry.source.is_external_flashblocks());
16235                            streams.normalize_next_index();
16236                        }
16237                        self.invalidate_preconfirmation_snapshot();
16238                        if self.config.preconfirmations == PreconfirmationMode::Required {
16239                            return Err(SubscriberError::Provider(
16240                                "required external Flashblock update channel closed".into(),
16241                            ));
16242                        }
16243                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16244                    }
16245                    // Persist the missing-source intent before the first await.
16246                    // If a control command cancels this poll during reconnect,
16247                    // the next poll will reconcile the desired/live diff.
16248                    if source.is_flashblocks() {
16249                        self.invalidate_flashblock_generation();
16250                        return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16251                    }
16252                    self.sources_dirty = true;
16253                    self.bump_stream_revision();
16254                    if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
16255                        self.sources_dirty = false;
16256                        if let Some(backfill_event) =
16257                            self.normalize_flashblock_event(backfill_event).await?
16258                        {
16259                            self.verify_event_log_blocks(&backfill_event).await?;
16260                            return Ok(Some(backfill_event));
16261                        }
16262                    }
16263                    self.sources_dirty = false;
16264                }
16265                event => {
16266                    let Some(event) = self.normalize_flashblock_event(event).await? else {
16267                        continue;
16268                    };
16269                    self.verify_event_log_blocks(&event).await?;
16270                    return Ok(Some(event));
16271                }
16272            }
16273        }
16274    }
16275
16276    fn invalidate_flashblock_generation(&mut self) {
16277        self.pending_records
16278            .retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
16279        self.pending_preconfirmation_invalidation = true;
16280        self.reset_flashblock_tracking();
16281        if let Some(provider) = self.provider_ref.as_mut() {
16282            provider.generation = provider.generation.saturating_add(1);
16283        }
16284        if let AlloySubscriberState::Active(streams) = &mut self.state {
16285            streams
16286                .entries
16287                .retain(|entry| !entry.source.is_flashblocks());
16288            streams.normalize_next_index();
16289        }
16290        let reconnect_sources = self
16291            .stream_sources()
16292            .unwrap_or_default()
16293            .into_iter()
16294            .filter(SubscriberStreamSource::is_flashblocks)
16295            .collect::<Vec<_>>();
16296        self.pending_flashblock_reconnects.clear();
16297        self.pending_flashblock_reconnect_sources.clear();
16298        if self.config.preconfirmations == PreconfirmationMode::Required
16299            || self.config.reconnect.enabled
16300        {
16301            for source in reconnect_sources {
16302                self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
16303            }
16304        }
16305        self.sources_dirty = false;
16306        self.bump_stream_revision();
16307    }
16308
16309    async fn normalize_flashblock_event(
16310        &mut self,
16311        event: SubscriberEvent<N>,
16312    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16313        match event {
16314            #[cfg(feature = "raw-flashblocks-json")]
16315            SubscriberEvent::ExternalFlashblockUpdate(queued) => {
16316                let provider = queued.update.provider().clone();
16317                match self.ingest_flashblock_update(queued.update) {
16318                    Ok(()) => {
16319                        let _ = queued.acknowledgement.send(Ok(()));
16320                        Ok(Some(SubscriberEvent::FlashblockObserved))
16321                    }
16322                    Err(error)
16323                        if self.config.preconfirmations == PreconfirmationMode::Preferred =>
16324                    {
16325                        let recoverable_capacity =
16326                            matches!(error, SubscriberError::ResourceExhausted(_));
16327                        if !recoverable_capacity
16328                            && let Some(configured) = self.external_flashblocks_provider.as_mut()
16329                            && configured.endpoint == provider.endpoint
16330                        {
16331                            self.rejected_external_flashblock_generation = Some(
16332                                self.rejected_external_flashblock_generation
16333                                    .map_or(provider.generation, |rejected| {
16334                                        rejected.max(provider.generation)
16335                                    }),
16336                            );
16337                            configured.generation = configured
16338                                .generation
16339                                .max(provider.generation.saturating_add(1));
16340                        }
16341                        self.invalidate_preconfirmation_snapshot();
16342                        self.last_external_flashblock_snapshot = None;
16343                        let _ = queued
16344                            .acknowledgement
16345                            .send(Err(FlashblockUpdateChannelError::Rejected));
16346                        tracing::warn!(
16347                            provider = %provider.endpoint,
16348                            generation = provider.generation,
16349                            error = %error,
16350                            "external Flashblock update rejected; canonical delivery remains active"
16351                        );
16352                        Ok(Some(SubscriberEvent::FlashblockInvalidated))
16353                    }
16354                    Err(error) => {
16355                        let _ = queued
16356                            .acknowledgement
16357                            .send(Err(FlashblockUpdateChannelError::Rejected));
16358                        Err(error)
16359                    }
16360                }
16361            }
16362            SubscriberEvent::BasePendingLog { source_id, log } => {
16363                let block_number = log.block_number.ok_or_else(|| {
16364                    SubscriberError::Provider(
16365                        "pendingLogs item is missing its pending block number".into(),
16366                    )
16367                })?;
16368                let transaction_hash = log.transaction_hash.ok_or_else(|| {
16369                    SubscriberError::Provider(
16370                        "pendingLogs item is missing its transaction hash".into(),
16371                    )
16372                })?;
16373                let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
16374                    flashblock.block_number == block_number
16375                        && flashblock.contains_transaction(&transaction_hash)
16376                });
16377                let Some(flashblock) = matching.cloned() else {
16378                    if self
16379                        .latest_preconfirmation
16380                        .as_ref()
16381                        .is_some_and(|latest| block_number < latest.block_number)
16382                    {
16383                        return Ok(None);
16384                    }
16385                    if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
16386                        return Err(SubscriberError::ResourceExhausted(
16387                            "unmatched pendingLogs exceeded max_pending_records".into(),
16388                        ));
16389                    }
16390                    self.unmatched_pending_logs.push_back((source_id, log));
16391                    return Ok(None);
16392                };
16393                let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
16394                Ok(Some(if logs.is_empty() {
16395                    SubscriberEvent::FlashblockObserved
16396                } else {
16397                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
16398                }))
16399            }
16400            SubscriberEvent::BaseFlashblock(payload) => {
16401                let (flashblock, recover_pending_snapshot) =
16402                    self.accept_base_flashblock(payload)?;
16403                let mut logs = Vec::new();
16404                let mut retained = VecDeque::new();
16405                while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() {
16406                    let transaction_hash = log.transaction_hash;
16407                    if log.block_number == Some(flashblock.block_number)
16408                        && transaction_hash
16409                            .as_ref()
16410                            .is_some_and(|hash| flashblock.contains_transaction(hash))
16411                    {
16412                        let _ = source_id;
16413                        logs.push(log);
16414                    } else if log
16415                        .block_number
16416                        .is_some_and(|number| number >= flashblock.block_number)
16417                    {
16418                        retained.push_back((source_id, log));
16419                    } else {
16420                        // A late log for an older speculative block can no
16421                        // longer be applied to the active cumulative branch.
16422                    }
16423                }
16424                self.unmatched_pending_logs = retained;
16425
16426                let indexed_recovery = recover_pending_snapshot.then(|| {
16427                    let payload_id = flashblock
16428                        .payload_id
16429                        .expect("indexed recovery carries a payload id");
16430                    let index = flashblock.index.expect("indexed recovery carries an index");
16431                    let last_diff = self
16432                        .base_flashblock_transactions
16433                        .as_ref()
16434                        .filter(|(known_payload, known_index, _, _)| {
16435                            *known_payload == payload_id && *known_index == index
16436                        })
16437                        .map(|(_, _, _, last_diff)| last_diff.clone())
16438                        .unwrap_or_default();
16439                    (payload_id, index, last_diff)
16440                });
16441                if recover_pending_snapshot {
16442                    if let Some(event) = self
16443                        .fetch_pending_flashblock(indexed_recovery)
16444                        .await
16445                        .map_err(PendingFlashblockPollError::into_subscriber)?
16446                    {
16447                        return Ok(Some(event));
16448                    }
16449                    self.invalidate_flashblock_generation();
16450                    return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16451                }
16452                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
16453                Ok(Some(if logs.is_empty() {
16454                    SubscriberEvent::FlashblockObserved
16455                } else {
16456                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
16457                }))
16458            }
16459            SubscriberEvent::OpFlashblockTick => self.poll_op_pending_flashblock().await,
16460            SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await,
16461            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
16462                let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
16463                Ok(Some(if logs.is_empty() {
16464                    SubscriberEvent::FlashblockObserved
16465                } else {
16466                    SubscriberEvent::PreconfirmedLogs { flashblock, logs }
16467                }))
16468            }
16469            SubscriberEvent::FlashblockObserved => Ok(None),
16470            event => Ok(Some(event)),
16471        }
16472    }
16473
16474    async fn fetch_certified_canonical_head(
16475        &mut self,
16476    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16477        tokio::time::timeout(
16478            self.config.canonical_head_request_timeout,
16479            self.fetch_certified_canonical_head_inner(),
16480        )
16481        .await
16482        .map_err(|_| {
16483            SubscriberError::Provider(format!(
16484                "canonical head certification timed out after {:?}",
16485                self.config.canonical_head_request_timeout
16486            ))
16487        })?
16488    }
16489
16490    async fn fetch_certified_canonical_head_inner(
16491        &mut self,
16492    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16493        if self.chain_id.and_then(flashblocks_adapter)
16494            == Some(FlashblocksAdapter::PendingStatePolling)
16495        {
16496            if !self.reserve_flashblock_rpc_methods(2) {
16497                return Ok(None);
16498            }
16499            self.flashblocks_rpc_metrics.pending_block_requests = self
16500                .flashblocks_rpc_metrics
16501                .pending_block_requests
16502                .saturating_add(1);
16503            let pending = self
16504                .fetch_op_pending_block()
16505                .await
16506                .map_err(PendingFlashblockPollError::into_subscriber)?
16507                .ok_or_else(|| {
16508                    SubscriberError::Provider(
16509                        "provider returned no OP pending block while certifying its parent".into(),
16510                    )
16511                })?;
16512            let header = self
16513                .certify_op_pending_parent(&pending)
16514                .await
16515                .map_err(PendingFlashblockPollError::into_subscriber)?;
16516            let certified = BlockRef {
16517                number: header.number(),
16518                hash: header.hash(),
16519                parent_hash: Some(header.parent_hash()),
16520                timestamp: Some(header.timestamp()),
16521            };
16522            if self.last_certified_canonical_head.as_ref() == Some(&certified) {
16523                return Ok(None);
16524            }
16525            self.last_certified_canonical_head = Some(certified);
16526            return Ok(Some(SubscriberEvent::BlockHeader(header)));
16527        }
16528        self.flashblocks_rpc_metrics.canonical_head_requests = self
16529            .flashblocks_rpc_metrics
16530            .canonical_head_requests
16531            .saturating_add(1);
16532        let block = self
16533            .provider
16534            .get_block_by_number(BlockNumberOrTag::Latest)
16535            .await
16536            .map_err(provider_error)?
16537            .ok_or_else(|| {
16538                SubscriberError::Provider(
16539                    "provider returned no latest block while certifying canonical head".into(),
16540                )
16541            })?;
16542        let header = block.header();
16543        if header.hash().is_zero() {
16544            return Err(SubscriberError::Provider(
16545                "provider returned a placeholder hash for the latest canonical head".into(),
16546            ));
16547        }
16548        let certified = BlockRef {
16549            number: header.number(),
16550            hash: header.hash(),
16551            parent_hash: Some(header.parent_hash()),
16552            timestamp: Some(header.timestamp()),
16553        };
16554        if self.last_certified_canonical_head.as_ref() == Some(&certified) {
16555            return Ok(None);
16556        }
16557        self.last_certified_canonical_head = Some(certified);
16558        Ok(Some(SubscriberEvent::BlockHeader(header.clone())))
16559    }
16560
16561    fn accept_base_flashblock(
16562        &mut self,
16563        payload: BaseFlashblockWirePayload,
16564    ) -> Result<(FlashblockRef, bool), SubscriberError> {
16565        let provider = self.provider_ref.clone().ok_or({
16566            SubscriberError::InvalidConfig(
16567                "Flashblocks require a stable provider ref from a pinned provider lease",
16568            )
16569        })?;
16570
16571        let (flashblock, recover_pending_snapshot) = match payload {
16572            BaseFlashblockWirePayload::Indexed(payload) => {
16573                if payload.index == 0 {
16574                    let base = payload.base.clone().ok_or_else(|| {
16575                        SubscriberError::Provider(
16576                            "indexed newFlashblocks item zero omitted its base header".into(),
16577                        )
16578                    })?;
16579                    self.base_flashblock_header = Some((payload.payload_id, base));
16580                }
16581
16582                let base = self
16583                    .base_flashblock_header
16584                    .as_ref()
16585                    .filter(|(payload_id, _)| *payload_id == payload.payload_id)
16586                    .map(|(_, base)| base);
16587                let block_number = base.map(|base| base.block_number).or_else(|| {
16588                    payload
16589                        .metadata
16590                        .as_ref()
16591                        .map(|metadata| metadata.block_number)
16592                });
16593                let block_number = block_number.ok_or_else(|| {
16594                    SubscriberError::Provider(
16595                        "indexed newFlashblocks payload omitted both base and metadata block number"
16596                            .into(),
16597                    )
16598                })?;
16599                let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
16600                let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
16601                    Some((known_payload, known_index, transactions, last_diff))
16602                        if *known_payload == payload.payload_id =>
16603                    {
16604                        if payload.index < *known_index {
16605                            return self
16606                                .latest_preconfirmation
16607                                .clone()
16608                                .map(|flashblock| (flashblock, false))
16609                                .ok_or_else(|| {
16610                                    SubscriberError::Provider(
16611                                        "regressive indexed Flashblock arrived without an active snapshot"
16612                                            .into(),
16613                                    )
16614                                });
16615                        }
16616                        if payload.index == *known_index {
16617                            if *last_diff != diff_transactions {
16618                                return Err(SubscriberError::Provider(
16619                                    "conflicting duplicate indexed Flashblock payload".into(),
16620                                ));
16621                            }
16622                        } else {
16623                            if diff_transactions
16624                                .iter()
16625                                .any(|hash| transactions.contains(hash))
16626                            {
16627                                return Err(SubscriberError::Provider(
16628                                    "indexed Flashblock repeated a transaction from an earlier diff"
16629                                        .into(),
16630                                ));
16631                            }
16632                            transactions.extend(diff_transactions.iter().copied());
16633                            *known_index = payload.index;
16634                            *last_diff = diff_transactions;
16635                        }
16636                        transactions.clone()
16637                    }
16638                    _ => {
16639                        self.base_flashblock_transactions = Some((
16640                            payload.payload_id,
16641                            payload.index,
16642                            diff_transactions.clone(),
16643                            diff_transactions.clone(),
16644                        ));
16645                        diff_transactions
16646                    }
16647                };
16648                let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
16649                let transactions_root = payload
16650                    .diff
16651                    .transactions_root
16652                    .and_then(non_placeholder_hash);
16653                let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
16654                let state_root = non_placeholder_hash(payload.diff.state_root);
16655                let timestamp = base.map(|base| base.timestamp);
16656                let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
16657                let beneficiary = base.and_then(|base| base.beneficiary);
16658                let prevrandao = base
16659                    .and_then(|base| base.prevrandao)
16660                    .and_then(non_placeholder_hash);
16661                let gas_limit = base.and_then(|base| base.gas_limit);
16662                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16663                    provider: &provider,
16664                    payload_id: Some(payload.payload_id),
16665                    index: Some(payload.index),
16666                    block_number,
16667                    partial_block_hash,
16668                    parent_hash,
16669                    state_root,
16670                    transactions_root,
16671                    transaction_hashes: &transaction_hashes,
16672                    timestamp,
16673                    base_fee_per_gas,
16674                    beneficiary,
16675                    prevrandao,
16676                    gas_limit,
16677                });
16678                let flashblock = FlashblockRef {
16679                    provider,
16680                    payload_id: Some(payload.payload_id),
16681                    index: Some(payload.index),
16682                    block_number,
16683                    content_hash,
16684                    partial_block_hash,
16685                    parent_hash,
16686                    state_root,
16687                    transactions_root,
16688                    transaction_hashes,
16689                    timestamp,
16690                    base_fee_per_gas,
16691                    beneficiary,
16692                    prevrandao,
16693                    gas_limit,
16694                };
16695                if let Some(previous) = self.latest_preconfirmation.as_ref()
16696                    && previous.same_payload(&flashblock)
16697                    && previous.index == flashblock.index
16698                    && previous.content_hash != flashblock.content_hash
16699                {
16700                    return Err(SubscriberError::Provider(
16701                        "conflicting duplicate indexed Flashblock content".into(),
16702                    ));
16703                }
16704                let recover = match self.latest_preconfirmation.as_ref() {
16705                    Some(previous) if previous.same_payload(&flashblock) => {
16706                        if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
16707                        {
16708                            if current < previous {
16709                                return Ok((flashblock, false));
16710                            }
16711                            current > previous.saturating_add(1)
16712                        } else {
16713                            false
16714                        }
16715                    }
16716                    Some(_) => payload.index != 0,
16717                    None => payload.index != 0,
16718                };
16719                (flashblock, recover)
16720            }
16721            BaseFlashblockWirePayload::Block(payload) => {
16722                let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
16723                let parent_hash = non_placeholder_hash(payload.parent_hash);
16724                let state_root = non_placeholder_hash(payload.state_root);
16725                let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
16726                let partial_block_hash = non_placeholder_hash(payload.hash);
16727                let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
16728                let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16729                    provider: &provider,
16730                    payload_id: None,
16731                    index: None,
16732                    block_number: payload.number,
16733                    partial_block_hash,
16734                    parent_hash,
16735                    state_root,
16736                    transactions_root,
16737                    transaction_hashes: &transaction_hashes,
16738                    timestamp: Some(payload.timestamp),
16739                    base_fee_per_gas: payload.base_fee_per_gas,
16740                    beneficiary: payload.miner,
16741                    prevrandao,
16742                    gas_limit: payload.gas_limit,
16743                });
16744                let flashblock = FlashblockRef {
16745                    provider,
16746                    payload_id: None,
16747                    index: None,
16748                    block_number: payload.number,
16749                    content_hash,
16750                    partial_block_hash,
16751                    parent_hash,
16752                    state_root,
16753                    transactions_root,
16754                    transaction_hashes,
16755                    timestamp: Some(payload.timestamp),
16756                    base_fee_per_gas: payload.base_fee_per_gas,
16757                    beneficiary: payload.miner,
16758                    prevrandao,
16759                    gas_limit: payload.gas_limit,
16760                };
16761                if let Some(previous) = self.latest_preconfirmation.as_ref()
16762                    && flashblock.same_payload(previous)
16763                    && flashblock.content_hash != previous.content_hash
16764                    && !flashblock.is_cumulative_successor_of(previous)
16765                {
16766                    return Err(SubscriberError::Provider(
16767                        "cumulative Flashblock transaction membership is non-monotonic".into(),
16768                    ));
16769                }
16770                (flashblock, false)
16771            }
16772        };
16773        Ok((flashblock, recover_pending_snapshot))
16774    }
16775
16776    async fn poll_op_pending_flashblock(
16777        &mut self,
16778    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
16779        match self.fetch_pending_flashblock(None).await {
16780            Ok(event) => {
16781                self.consecutive_flashblock_poll_failures = 0;
16782                Ok(event)
16783            }
16784            Err(PendingFlashblockPollError::Request(error)) => {
16785                self.flashblocks_rpc_metrics.failed_requests = self
16786                    .flashblocks_rpc_metrics
16787                    .failed_requests
16788                    .saturating_add(1);
16789                self.consecutive_flashblock_poll_failures =
16790                    self.consecutive_flashblock_poll_failures.saturating_add(1);
16791                if self.consecutive_flashblock_poll_failures
16792                    >= self.config.max_consecutive_flashblock_poll_failures
16793                {
16794                    return Err(error);
16795                }
16796                tracing::warn!(
16797                    consecutive_failures = self.consecutive_flashblock_poll_failures,
16798                    failure_limit = self.config.max_consecutive_flashblock_poll_failures,
16799                    error = %error,
16800                    "Optimism pending-state Flashblocks request failed; retrying on the next tick"
16801                );
16802                Ok(None)
16803            }
16804            Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
16805        }
16806    }
16807
16808    async fn fetch_pending_flashblock(
16809        &mut self,
16810        indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
16811    ) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
16812        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
16813            == Some(FlashblocksAdapter::PendingStatePolling);
16814        if samples_pending_range {
16815            let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
16816            if !self.reserve_flashblock_rpc_methods(fixed_methods) {
16817                return Ok(None);
16818            }
16819        }
16820        let state_provider = if samples_pending_range {
16821            self.flashblocks_state_provider
16822                .as_ref()
16823                .unwrap_or(&self.provider)
16824        } else {
16825            &self.provider
16826        };
16827        let latest = if samples_pending_range {
16828            None
16829        } else {
16830            self.flashblocks_rpc_metrics.canonical_head_requests = self
16831                .flashblocks_rpc_metrics
16832                .canonical_head_requests
16833                .saturating_add(1);
16834            Some(
16835                state_provider
16836                    .get_block_number()
16837                    .await
16838                    .map_err(pending_flashblock_request_error)?,
16839            )
16840        };
16841        self.flashblocks_rpc_metrics.pending_block_requests = self
16842            .flashblocks_rpc_metrics
16843            .pending_block_requests
16844            .saturating_add(1);
16845        let pending_block = if samples_pending_range {
16846            self.fetch_op_pending_block().await?
16847        } else {
16848            self.provider
16849                .get_block_by_number(BlockNumberOrTag::Pending)
16850                .await
16851                .map_err(pending_flashblock_request_error)?
16852        };
16853        let Some(block) = pending_block else {
16854            if self.config.preconfirmations == PreconfirmationMode::Required {
16855                return Err(PendingFlashblockPollError::Request(
16856                    SubscriberError::Provider(
16857                        "Flashblocks provider returned no pending block".into(),
16858                    ),
16859                ));
16860            }
16861            return Ok(None);
16862        };
16863        let latest = if samples_pending_range {
16864            self.certify_op_pending_parent(&block).await?.number()
16865        } else {
16866            latest.expect("non-OP pending recovery fetched a canonical height")
16867        };
16868        let header = block.header();
16869        if header.number() <= latest {
16870            return Ok(None);
16871        }
16872
16873        let provider = self.provider_ref.clone().ok_or({
16874            PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
16875                "Flashblocks require a stable provider ref from a pinned provider lease",
16876            ))
16877        })?;
16878        let parent_hash = Some(header.parent_hash());
16879        let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
16880            hashes.to_vec()
16881        } else if let Some(transactions) = block.transactions().as_transactions() {
16882            transactions
16883                .iter()
16884                .map(|transaction| transaction.tx_hash())
16885                .collect()
16886        } else {
16887            Vec::new()
16888        };
16889        let state_root = non_placeholder_hash(header.state_root());
16890        let transactions_root = non_placeholder_hash(header.transactions_root());
16891        let partial_block_hash = non_placeholder_hash(header.hash());
16892        let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
16893        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
16894            provider: &provider,
16895            payload_id: None,
16896            index: None,
16897            block_number: header.number(),
16898            partial_block_hash,
16899            parent_hash,
16900            state_root,
16901            transactions_root,
16902            transaction_hashes: &transaction_hashes,
16903            timestamp: Some(header.timestamp()),
16904            base_fee_per_gas: header.base_fee_per_gas(),
16905            beneficiary: Some(header.beneficiary()),
16906            prevrandao,
16907            gas_limit: Some(header.gas_limit()),
16908        });
16909        let flashblock = FlashblockRef {
16910            provider,
16911            payload_id: None,
16912            index: None,
16913            block_number: header.number(),
16914            content_hash,
16915            partial_block_hash,
16916            parent_hash,
16917            state_root,
16918            transactions_root,
16919            transaction_hashes,
16920            timestamp: Some(header.timestamp()),
16921            base_fee_per_gas: header.base_fee_per_gas(),
16922            beneficiary: Some(header.beneficiary()),
16923            prevrandao,
16924            gas_limit: Some(header.gas_limit()),
16925        };
16926        if samples_pending_range
16927            && self
16928                .latest_preconfirmation
16929                .as_ref()
16930                .is_some_and(|previous| !previous.same_payload(&flashblock))
16931        {
16932            // Revoke as soon as the sampled payload changes, before any
16933            // follow-up receipt await can fail or be cancelled.
16934            self.invalidate_preconfirmation_snapshot();
16935        }
16936        if let Some((payload_id, index, last_diff)) = indexed_recovery {
16937            self.base_flashblock_transactions = Some((
16938                payload_id,
16939                index,
16940                flashblock.transaction_hashes.clone(),
16941                last_diff,
16942            ));
16943        }
16944        let repeats_pending_snapshot = self
16945            .latest_preconfirmation
16946            .as_ref()
16947            .is_some_and(|previous| previous == &flashblock);
16948        if repeats_pending_snapshot && !samples_pending_range {
16949            return Ok(None);
16950        }
16951
16952        if let Some(previous) = self.latest_preconfirmation.as_ref()
16953            && flashblock.same_payload(previous)
16954            && !flashblock.is_cumulative_successor_of(previous)
16955        {
16956            if samples_pending_range {
16957                // OP pending-state reads are not atomic and paid endpoints can
16958                // briefly expose a shorter backend view. Never publish the
16959                // regression. Revoke the active overlay and require a fresh,
16960                // internally coherent sample on a later tick instead.
16961                self.invalidate_preconfirmation_snapshot();
16962                return Ok(Some(SubscriberEvent::FlashblockInvalidated));
16963            }
16964            return Err(PendingFlashblockPollError::Integrity(
16965                SubscriberError::Provider(
16966                    "sampled cumulative Flashblock transaction membership is non-monotonic".into(),
16967                ),
16968            ));
16969        }
16970
16971        let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
16972        if samples_pending_range {
16973            let (mut receipt_logs, completed_receipts, unavailable_receipts) =
16974                self.fetch_pending_transaction_receipts(&flashblock).await?;
16975            logs.append(&mut receipt_logs);
16976            logs.retain(|log| log.block_number == Some(flashblock.block_number));
16977            for log in &logs {
16978                let transaction_hash = log.transaction_hash.ok_or_else(|| {
16979                    PendingFlashblockPollError::Integrity(SubscriberError::Provider(
16980                        "pre-confirmed log is missing its transaction hash".into(),
16981                    ))
16982                })?;
16983                if !flashblock.contains_transaction(&transaction_hash) {
16984                    self.flashblocks_rpc_metrics.raced_samples =
16985                        self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
16986                    return Ok(None);
16987                }
16988            }
16989            let logs = self
16990                .filter_preconfirmed_logs(&flashblock, logs)
16991                .map_err(PendingFlashblockPollError::Integrity)?;
16992            self.preconfirmed_unavailable_receipts
16993                .extend(unavailable_receipts);
16994            for transaction_hash in &completed_receipts {
16995                self.preconfirmed_unavailable_receipts
16996                    .remove(transaction_hash);
16997            }
16998            self.preconfirmed_receipted_transactions
16999                .extend(completed_receipts);
17000            if repeats_pending_snapshot && logs.is_empty() {
17001                return Ok(None);
17002            }
17003            return Ok(Some(if logs.is_empty() {
17004                SubscriberEvent::FlashblockObserved
17005            } else {
17006                SubscriberEvent::PreconfirmedLogs { flashblock, logs }
17007            }));
17008        }
17009        let logs = self
17010            .filter_preconfirmed_logs(&flashblock, logs)
17011            .map_err(PendingFlashblockPollError::Integrity)?;
17012        Ok(Some(if logs.is_empty() {
17013            SubscriberEvent::FlashblockObserved
17014        } else {
17015            SubscriberEvent::PreconfirmedLogs { flashblock, logs }
17016        }))
17017    }
17018
17019    async fn fetch_pending_logs(
17020        &mut self,
17021        pending_block_number: u64,
17022    ) -> Result<Vec<Log>, PendingFlashblockPollError> {
17023        let mut logs = Vec::new();
17024        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
17025            == Some(FlashblocksAdapter::PendingStatePolling);
17026        let state_provider = if samples_pending_range {
17027            self.flashblocks_state_provider
17028                .as_ref()
17029                .unwrap_or(&self.provider)
17030        } else {
17031            &self.provider
17032        };
17033        for filter in self.log_stream_filters() {
17034            self.flashblocks_rpc_metrics.pending_log_requests = self
17035                .flashblocks_rpc_metrics
17036                .pending_log_requests
17037                .saturating_add(1);
17038            let filter = if samples_pending_range {
17039                filter
17040                    .from_block(pending_block_number)
17041                    .to_block(BlockNumberOrTag::Pending)
17042            } else {
17043                filter
17044                    .from_block(BlockNumberOrTag::Pending)
17045                    .to_block(BlockNumberOrTag::Pending)
17046            };
17047            logs.extend(
17048                state_provider
17049                    .get_logs(&filter)
17050                    .await
17051                    .map_err(pending_flashblock_request_error)?,
17052            );
17053        }
17054        if samples_pending_range {
17055            logs.retain(|log| log.block_number == Some(pending_block_number));
17056        }
17057        Ok(logs)
17058    }
17059
17060    async fn fetch_pending_transaction_receipts(
17061        &mut self,
17062        flashblock: &FlashblockRef,
17063    ) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
17064        let receipt_allowance = self.pending_receipt_request_allowance();
17065        let receipt_limit = self
17066            .config
17067            .max_pending_transaction_receipts_per_tick
17068            .min(receipt_allowance);
17069        if receipt_limit == 0 {
17070            return Ok((Vec::new(), Vec::new(), Vec::new()));
17071        }
17072        let mut transaction_hashes = Vec::with_capacity(receipt_limit);
17073        for transaction_hash in &flashblock.transaction_hashes {
17074            if !self
17075                .preconfirmed_receipted_transactions
17076                .contains(transaction_hash)
17077                && !self
17078                    .preconfirmed_unavailable_receipts
17079                    .contains(transaction_hash)
17080            {
17081                transaction_hashes.push(*transaction_hash);
17082                if transaction_hashes.len() == receipt_limit {
17083                    break;
17084                }
17085            }
17086        }
17087        if transaction_hashes.len() < receipt_limit {
17088            for transaction_hash in &flashblock.transaction_hashes {
17089                if self
17090                    .preconfirmed_unavailable_receipts
17091                    .contains(transaction_hash)
17092                {
17093                    transaction_hashes.push(*transaction_hash);
17094                    if transaction_hashes.len() == receipt_limit {
17095                        break;
17096                    }
17097                }
17098            }
17099        }
17100        if transaction_hashes.is_empty() {
17101            return Ok((Vec::new(), Vec::new(), Vec::new()));
17102        }
17103        let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
17104        debug_assert!(reserved, "receipt allowance must remain reserved until use");
17105        if !reserved {
17106            return Ok((Vec::new(), Vec::new(), Vec::new()));
17107        }
17108        self.flashblocks_rpc_metrics.pending_receipt_requests = self
17109            .flashblocks_rpc_metrics
17110            .pending_receipt_requests
17111            .saturating_add(transaction_hashes.len() as u64);
17112        let state_provider = self
17113            .flashblocks_state_provider
17114            .as_ref()
17115            .unwrap_or(&self.provider);
17116        let client = state_provider.client();
17117        let mut batch = BatchRequest::new(client);
17118        let mut waiters = Vec::with_capacity(transaction_hashes.len());
17119        for transaction_hash in transaction_hashes {
17120            let waiter = batch
17121                .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
17122                .map_err(pending_flashblock_request_error)?;
17123            waiters.push((transaction_hash, waiter));
17124        }
17125        batch
17126            .send()
17127            .await
17128            .map_err(pending_flashblock_request_error)?;
17129        let mut logs = Vec::new();
17130        let mut completed = Vec::new();
17131        let mut unavailable = Vec::new();
17132        for (transaction_hash, waiter) in waiters {
17133            let value = waiter.await.map_err(pending_flashblock_request_error)?;
17134            if let Some(mut receipt_logs) =
17135                normalize_pending_transaction_receipt(transaction_hash, value)
17136                    .map_err(PendingFlashblockPollError::Integrity)?
17137            {
17138                self.flashblocks_rpc_metrics.pending_receipts_completed = self
17139                    .flashblocks_rpc_metrics
17140                    .pending_receipts_completed
17141                    .saturating_add(1);
17142                logs.append(&mut receipt_logs);
17143                completed.push(transaction_hash);
17144            } else {
17145                self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
17146                    .flashblocks_rpc_metrics
17147                    .pending_receipts_unavailable
17148                    .saturating_add(1);
17149                unavailable.push(transaction_hash);
17150            }
17151        }
17152        Ok((logs, completed, unavailable))
17153    }
17154
17155    fn pending_receipt_request_allowance(&mut self) -> usize {
17156        self.prune_flashblock_rpc_request_times();
17157        let rolling_capacity = self
17158            .config
17159            .max_flashblock_rpc_requests_per_second
17160            .saturating_sub(self.flashblock_rpc_request_times.len());
17161        rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
17162    }
17163
17164    fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
17165        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
17166        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
17167        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
17168        self.pending_receipt_requests_per_second_capacity()
17169            .checked_div(ticks_per_second)
17170            .unwrap_or(0)
17171    }
17172
17173    fn pending_receipt_requests_per_second_capacity(&self) -> usize {
17174        let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
17175        let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
17176        let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
17177        let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
17178        let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
17179        if needs_header_block_stream(&self.interests) {
17180            let canonical_interval_nanos =
17181                self.config.canonical_head_poll_interval.as_nanos().max(1);
17182            let canonical_ticks = Duration::from_secs(1)
17183                .as_nanos()
17184                .div_ceil(canonical_interval_nanos);
17185            let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
17186            reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
17187        }
17188        self.config
17189            .max_flashblock_rpc_requests_per_second
17190            .saturating_sub(reserved_methods)
17191    }
17192
17193    fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
17194        self.prune_flashblock_rpc_request_times();
17195        if self
17196            .flashblock_rpc_request_times
17197            .len()
17198            .saturating_add(methods)
17199            > self.config.max_flashblock_rpc_requests_per_second
17200        {
17201            return false;
17202        }
17203        let now = Instant::now();
17204        for _ in 0..methods {
17205            self.flashblock_rpc_request_times.push_back(now);
17206        }
17207        true
17208    }
17209
17210    fn prune_flashblock_rpc_request_times(&mut self) {
17211        let now = Instant::now();
17212        while self
17213            .flashblock_rpc_request_times
17214            .front()
17215            .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
17216        {
17217            self.flashblock_rpc_request_times.pop_front();
17218        }
17219    }
17220
17221    fn filter_preconfirmed_logs(
17222        &mut self,
17223        flashblock: &FlashblockRef,
17224        mut logs: Vec<Log>,
17225    ) -> Result<Vec<Log>, SubscriberError> {
17226        let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
17227            == Some(FlashblocksAdapter::PendingStatePolling);
17228        if self
17229            .latest_preconfirmation
17230            .as_ref()
17231            .is_some_and(|previous| !previous.same_payload(flashblock))
17232        {
17233            // A new payload revokes the previous overlay even when none of the
17234            // caller's log filters matched in the replacement. Otherwise a
17235            // quiet block could leave stale speculative signing authority
17236            // active until an unrelated canonical pool event arrived.
17237            self.invalidate_preconfirmation_snapshot();
17238        }
17239        if self
17240            .latest_preconfirmation
17241            .as_ref()
17242            .is_none_or(|previous| !previous.same_payload(flashblock))
17243        {
17244            self.preconfirmed_seen_logs.clear();
17245        }
17246        if let Some(previous) = self.latest_preconfirmation.as_ref()
17247            && previous.same_payload(flashblock)
17248            && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
17249            && current_index < previous_index
17250        {
17251            return Ok(Vec::new());
17252        }
17253        self.latest_preconfirmation = Some(flashblock.clone());
17254
17255        logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
17256        let mut filtered = Vec::new();
17257        for mut log in logs {
17258            if log.removed || log.block_number != Some(flashblock.block_number) {
17259                return Err(SubscriberError::Provider(
17260                    "pre-confirmed log disagrees with its Flashblock snapshot".into(),
17261                ));
17262            }
17263            let transaction_hash = log.transaction_hash.ok_or_else(|| {
17264                SubscriberError::Provider(
17265                    "pre-confirmed log is missing its transaction hash".into(),
17266                )
17267            })?;
17268            let log_index = log.log_index.ok_or_else(|| {
17269                SubscriberError::Provider("pre-confirmed log is missing its log index".into())
17270            })?;
17271            let transaction_index =
17272                flashblock
17273                    .transaction_index(&transaction_hash)
17274                    .ok_or_else(|| {
17275                        SubscriberError::Provider(
17276                        "pre-confirmed log transaction is absent from the cumulative Flashblock"
17277                            .into(),
17278                    )
17279                    })?;
17280            if log
17281                .transaction_index
17282                .is_some_and(|reported| reported != transaction_index)
17283            {
17284                return Err(SubscriberError::Provider(
17285                    "pre-confirmed log transaction index disagrees with cumulative membership"
17286                        .into(),
17287                ));
17288            }
17289            let reported_hash = log.block_hash.and_then(non_placeholder_hash);
17290            if !samples_pending_range
17291                && let Some(reported) = reported_hash
17292                && reported != flashblock.content_hash
17293                && flashblock
17294                    .partial_block_hash
17295                    .is_some_and(|expected| reported != expected)
17296            {
17297                return Err(SubscriberError::Provider(
17298                    "pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
17299                        .into(),
17300                ));
17301            }
17302            log.block_hash = Some(flashblock.content_hash);
17303            log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
17304            log.transaction_index = Some(transaction_index);
17305            if self
17306                .preconfirmed_seen_logs
17307                .insert((transaction_hash, log_index))
17308                && log_matches_any_interest(&log, &self.interests)
17309            {
17310                filtered.push(log);
17311            }
17312        }
17313        Ok(filtered)
17314    }
17315
17316    async fn verify_event_log_blocks(
17317        &mut self,
17318        event: &SubscriberEvent<N>,
17319    ) -> Result<(), SubscriberError> {
17320        if !self.config.verify_log_block_context {
17321            return Ok(());
17322        }
17323        match event {
17324            SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
17325            SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
17326                for log in logs {
17327                    self.verify_log_block_context(log).await?;
17328                }
17329                Ok(())
17330            }
17331            #[cfg(feature = "raw-flashblocks-json")]
17332            SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()),
17333            SubscriberEvent::BlockHeader(_)
17334            | SubscriberEvent::PendingHash(_)
17335            | SubscriberEvent::PendingHashes(_)
17336            | SubscriberEvent::BasePendingLog { .. }
17337            | SubscriberEvent::BaseFlashblock(_)
17338            | SubscriberEvent::OpFlashblockTick
17339            | SubscriberEvent::CanonicalHeadTick
17340            | SubscriberEvent::PreconfirmedLogs { .. }
17341            | SubscriberEvent::FlashblockInvalidated
17342            | SubscriberEvent::FlashblockObserved
17343            | SubscriberEvent::StreamTerminated(_) => Ok(()),
17344        }
17345    }
17346
17347    async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
17348        if log.removed {
17349            return Ok(());
17350        }
17351        let number = log.block_number.ok_or_else(|| {
17352            SubscriberError::Provider(
17353                "canonical log is missing its block number during context verification".into(),
17354            )
17355        })?;
17356        let hash = log.block_hash.ok_or_else(|| {
17357            SubscriberError::Provider(
17358                "canonical log is missing its block hash during context verification".into(),
17359            )
17360        })?;
17361        let key = (number, hash);
17362        if self.verified_log_blocks.contains_key(&key) {
17363            return Ok(());
17364        }
17365        let provider = self
17366            .log_verification_provider
17367            .as_ref()
17368            .unwrap_or(&self.provider);
17369        let block = provider
17370            .get_block_by_number(BlockNumberOrTag::Number(number))
17371            .await
17372            .map_err(provider_error)?
17373            .ok_or_else(|| {
17374                SubscriberError::Provider(format!(
17375                    "canonical log block {number} is unavailable during context verification"
17376                ))
17377            })?;
17378        let header = block.header();
17379        let verified = BlockRef {
17380            number: header.number(),
17381            hash: header.hash(),
17382            parent_hash: Some(header.parent_hash()),
17383            timestamp: Some(header.timestamp()),
17384        };
17385        if verified.number != number
17386            || verified.hash != hash
17387            || log
17388                .block_timestamp
17389                .is_some_and(|timestamp| verified.timestamp != Some(timestamp))
17390        {
17391            return Err(SubscriberError::Provider(format!(
17392                "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
17393            )));
17394        }
17395        self.verified_log_blocks.insert(key, verified);
17396        self.verified_log_block_order.push_back(key);
17397        let capacity = self.config.reconnect.dedupe_window.max(1);
17398        while self.verified_log_block_order.len() > capacity {
17399            if let Some(evicted) = self.verified_log_block_order.pop_front() {
17400                self.verified_log_blocks.remove(&evicted);
17401            }
17402        }
17403        Ok(())
17404    }
17405
17406    fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
17407        self.enqueue_event_with_excluded_owners(event, None);
17408    }
17409
17410    fn buffer_reconcile_event_for_owners(
17411        &mut self,
17412        event: &SubscriberEvent<N>,
17413        target_epochs: &HashSet<SubscriberOwnerEpoch>,
17414    ) {
17415        match event {
17416            SubscriberEvent::Log { log, .. } => {
17417                self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
17418            }
17419            SubscriberEvent::BackfilledLogs { logs, .. } => {
17420                for log in logs {
17421                    self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
17422                }
17423            }
17424            SubscriberEvent::Logs(logs) => {
17425                for log in logs {
17426                    self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
17427                }
17428            }
17429            #[cfg(feature = "raw-flashblocks-json")]
17430            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
17431            SubscriberEvent::BlockHeader(_)
17432            | SubscriberEvent::PendingHash(_)
17433            | SubscriberEvent::PendingHashes(_)
17434            | SubscriberEvent::BasePendingLog { .. }
17435            | SubscriberEvent::BaseFlashblock(_)
17436            | SubscriberEvent::OpFlashblockTick
17437            | SubscriberEvent::CanonicalHeadTick
17438            | SubscriberEvent::PreconfirmedLogs { .. }
17439            | SubscriberEvent::FlashblockInvalidated
17440            | SubscriberEvent::FlashblockObserved
17441            | SubscriberEvent::StreamTerminated(_) => {}
17442        }
17443    }
17444
17445    fn buffer_reconcile_log_for_owners(
17446        &mut self,
17447        log: &Log,
17448        source: InputSource,
17449        target_epochs: &HashSet<SubscriberOwnerEpoch>,
17450    ) {
17451        let record = self.with_chain_id(log_input_record(log.clone(), source));
17452        let owners = self
17453            .staged_owners_for_record(&record)
17454            .into_iter()
17455            .filter(|owner| target_epochs.contains(owner))
17456            .collect::<Vec<_>>();
17457        if !owners.is_empty() {
17458            self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
17459        }
17460    }
17461
17462    fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
17463        let mut retained = VecDeque::new();
17464        while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
17465            let mut promoted = Vec::new();
17466            buffered.owners.retain(|owner| {
17467                if target_epochs.contains(owner) {
17468                    promoted.push(owner.clone());
17469                    false
17470                } else {
17471                    true
17472                }
17473            });
17474            if promoted.is_empty() {
17475                retained.push_back(buffered);
17476                continue;
17477            }
17478            let promoted_record = if buffered.owners.is_empty() {
17479                buffered.record
17480            } else {
17481                let record = buffered.record.clone();
17482                retained.push_back(buffered);
17483                record
17484            };
17485            self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
17486        }
17487        self.pending_reconcile_owner_records = retained;
17488    }
17489
17490    fn seed_reconciled_filter_anchors(
17491        &mut self,
17492        plans: &[SubscriberOwnerReconcilePlan<N>],
17493        through: u64,
17494    ) {
17495        for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
17496            let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
17497                continue;
17498            };
17499            let anchor = self
17500                .last_seen_log_blocks
17501                .entry(source_id)
17502                .or_insert(through);
17503            *anchor = (*anchor).max(through);
17504        }
17505    }
17506
17507    fn enqueue_event_excluding_owners(
17508        &mut self,
17509        event: SubscriberEvent<N>,
17510        excluded: &HashSet<SubscriberOwnerEpoch>,
17511    ) {
17512        self.enqueue_event_with_excluded_owners(event, Some(excluded));
17513    }
17514
17515    fn enqueue_event_with_excluded_owners(
17516        &mut self,
17517        event: SubscriberEvent<N>,
17518        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17519    ) {
17520        match event {
17521            SubscriberEvent::Log { source_id, log } => {
17522                if log_matches_any_interest(&log, &self.interests) {
17523                    let record = log_input_record(log, InputSource::Subscription);
17524                    self.note_log_block(source_id, &record);
17525                    self.enqueue_record_with_excluded_owners(record, excluded);
17526                }
17527            }
17528            SubscriberEvent::BackfilledLogs { source_id, logs } => {
17529                self.enqueue_backfilled_logs_with_excluded_owners(
17530                    logs,
17531                    Some(source_id),
17532                    None,
17533                    None,
17534                    excluded,
17535                );
17536            }
17537            SubscriberEvent::Logs(logs) => {
17538                for log in logs {
17539                    if log_matches_any_interest(&log, &self.interests) {
17540                        self.enqueue_record_with_excluded_owners(
17541                            log_input_record(log, InputSource::Poll),
17542                            excluded,
17543                        );
17544                    }
17545                }
17546            }
17547            SubscriberEvent::BlockHeader(header) => {
17548                if needs_header_block_stream(&self.interests) {
17549                    let record = block_header_input_record::<N>(header);
17550                    self.enqueue_record_with_excluded_owners(record, excluded);
17551                }
17552            }
17553            SubscriberEvent::PendingHash(hash) => {
17554                let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
17555                self.enqueue_record_with_excluded_owners(record, excluded);
17556            }
17557            SubscriberEvent::PendingHashes(hashes) => {
17558                for hash in hashes {
17559                    self.enqueue_record_with_excluded_owners(
17560                        pending_hash_input_record::<N>(hash, InputSource::Poll),
17561                        excluded,
17562                    );
17563                }
17564            }
17565            SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
17566                for log in logs {
17567                    let record = self
17568                        .with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
17569                    self.push_pending_record(SubscriberInputRecord {
17570                        record,
17571                        scope: SubscriberInputScope::Preconfirmed,
17572                    });
17573                }
17574            }
17575            SubscriberEvent::FlashblockInvalidated => {
17576                self.pending_preconfirmation_invalidation = true;
17577            }
17578            SubscriberEvent::BasePendingLog { .. }
17579            | SubscriberEvent::BaseFlashblock(_)
17580            | SubscriberEvent::OpFlashblockTick
17581            | SubscriberEvent::CanonicalHeadTick
17582            | SubscriberEvent::FlashblockObserved => {}
17583            #[cfg(feature = "raw-flashblocks-json")]
17584            SubscriberEvent::ExternalFlashblockUpdate(_) => {}
17585            SubscriberEvent::StreamTerminated(_) => {}
17586        }
17587    }
17588
17589    fn enqueue_backfilled_logs(
17590        &mut self,
17591        logs: Vec<Log>,
17592        source_id: Option<usize>,
17593        owner: Option<&SubscriberOwnerEpoch>,
17594        range: Option<SubscriberBackfill>,
17595    ) {
17596        self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
17597    }
17598
17599    fn enqueue_backfilled_logs_with_excluded_owners(
17600        &mut self,
17601        logs: Vec<Log>,
17602        source_id: Option<usize>,
17603        owner: Option<&SubscriberOwnerEpoch>,
17604        range: Option<SubscriberBackfill>,
17605        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17606    ) {
17607        for log in logs {
17608            if range.as_ref().is_some_and(|range| {
17609                log.block_number.is_some_and(|block| {
17610                    block < range.start_block() || range.end_block().is_some_and(|end| block > end)
17611                })
17612            }) {
17613                continue;
17614            }
17615            let matches = match owner {
17616                Some(epoch) => self
17617                    .owned_interests
17618                    .iter()
17619                    .find(|entry| entry.epoch.as_ref() == Some(epoch))
17620                    .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
17621                None => log_matches_any_interest(&log, &self.interests),
17622            };
17623            if matches {
17624                let record = log_input_record(log, InputSource::Backfill);
17625                if let Some(epoch) = owner {
17626                    self.enqueue_owner_record(record, epoch.clone());
17627                } else {
17628                    if let Some(source_id) = source_id {
17629                        self.note_log_block(source_id, &record);
17630                    }
17631                    self.enqueue_record_with_excluded_owners(record, excluded);
17632                }
17633            }
17634        }
17635    }
17636
17637    fn enqueue_compat_owner_backfilled_logs(
17638        &mut self,
17639        logs: Vec<Log>,
17640        owner: &HandlerId,
17641        range: SubscriberBackfill,
17642    ) {
17643        let interests = self
17644            .owned_interests
17645            .iter()
17646            .find(|entry| {
17647                &entry.owner == owner
17648                    && entry.epoch.is_none()
17649                    && entry.state == SubscriberOwnerState::Active
17650            })
17651            .map(|entry| entry.interests.clone());
17652        let Some(interests) = interests else {
17653            return;
17654        };
17655        for log in logs {
17656            if log.block_number.is_some_and(|block| {
17657                block < range.start_block() || range.end_block().is_some_and(|end| block > end)
17658            }) || !log_matches_any_interest(&log, &interests)
17659            {
17660                continue;
17661            }
17662            let record = log_input_record(log, InputSource::Backfill);
17663            self.enqueue_compat_owner_record(record, owner.clone());
17664        }
17665    }
17666
17667    async fn reconnect_source_stream(
17668        &mut self,
17669        source: SubscriberStreamSource,
17670    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17671        if !source.is_pubsub() {
17672            return Err(stream_terminated_error(&source));
17673        }
17674
17675        if !self.config.reconnect.enabled {
17676            return Err(SubscriberError::Provider(format!(
17677                "Alloy subscriber {} stream terminated and reconnect is disabled",
17678                source.label()
17679            )));
17680        }
17681
17682        let mut attempts = 0usize;
17683        let mut delay = self.config.reconnect.initial_delay;
17684        let mut retry_delay = self.config.reconnect.retry_delay;
17685
17686        loop {
17687            attempts = attempts.saturating_add(1);
17688            if !delay.is_zero() {
17689                tokio::time::sleep(delay).await;
17690            }
17691
17692            match self.reconnect_source_once(source.clone()).await {
17693                Ok(backfill_event) => return Ok(backfill_event),
17694                Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
17695                    return Err(SubscriberError::Provider(format!(
17696                        "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
17697                        source.label()
17698                    )));
17699                }
17700                Err(error) => {
17701                    tracing::warn!(
17702                        stream = source.label(),
17703                        attempts,
17704                        error = %error,
17705                        "Alloy subscriber reconnect attempt failed"
17706                    );
17707                    delay = retry_delay;
17708                    retry_delay =
17709                        next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
17710                }
17711            }
17712        }
17713    }
17714
17715    async fn reconnect_source_once(
17716        &mut self,
17717        source: SubscriberStreamSource,
17718    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17719        if matches!(
17720            &self.state,
17721            AlloySubscriberState::Active(streams) if streams.contains_source(&source)
17722        ) {
17723            // A prior attempt installed the stream before its catch-up await
17724            // failed or was cancelled. Retry only the unfinished historical
17725            // window; reconnecting again would create a duplicate live source.
17726            let backfill_event = self.backfill_reconnected_source(&source).await?;
17727            self.pending_source_backfills
17728                .retain(|pending| !pending.same_key(&source));
17729            return Ok(backfill_event);
17730        }
17731        let stream = self.connect_source_stream(source.clone()).await?;
17732        if !matches!(self.state, AlloySubscriberState::Active(_)) {
17733            return Err(SubscriberError::Provider(
17734                "Alloy subscriber state changed before reconnect completed".to_owned(),
17735            ));
17736        }
17737        self.install_source_stream(source.clone(), stream);
17738        if self.source_requires_backfill(&source) {
17739            self.queue_source_backfill(source.clone());
17740        }
17741        let backfill_event = self.backfill_reconnected_source(&source).await?;
17742        self.pending_source_backfills
17743            .retain(|pending| !pending.same_key(&source));
17744
17745        Ok(backfill_event)
17746    }
17747
17748    async fn backfill_reconnected_source(
17749        &mut self,
17750        source: &SubscriberStreamSource,
17751    ) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
17752        if source.is_flashblocks() {
17753            return Ok(None);
17754        }
17755        let SubscriberStreamSource::PubSubLog { id, filter } = source else {
17756            return Ok(None);
17757        };
17758        let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
17759            return Ok(None);
17760        };
17761
17762        let latest = self
17763            .provider
17764            .get_block_number()
17765            .await
17766            .map_err(provider_error)?;
17767        if latest < from_block {
17768            return Ok(None);
17769        }
17770
17771        let logs = self
17772            .provider
17773            .get_logs(&filter.clone().from_block(from_block).to_block(latest))
17774            .await
17775            .map_err(provider_error)?;
17776        Ok(Some(SubscriberEvent::BackfilledLogs {
17777            source_id: *id,
17778            logs,
17779        }))
17780    }
17781
17782    fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
17783        if let Some(block) = record.context.block.as_ref() {
17784            self.last_seen_log_blocks.insert(source_id, block.number);
17785        }
17786    }
17787
17788    fn enqueue_record_with_excluded_owners(
17789        &mut self,
17790        record: ReactiveInputRecord<N>,
17791        excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
17792    ) {
17793        let record = self.with_chain_id(record);
17794        let mut owners = self.staged_owners_for_record(&record);
17795        if let Some(excluded) = excluded {
17796            owners.retain(|owner| !excluded.contains(owner));
17797        }
17798        let canonical_duplicate = self.should_skip_recent_duplicate(&record);
17799        let owners = self.filter_recent_owner_duplicates(&record, owners);
17800        let compatibility_owners = self.compatibility_owners_for_record(&record);
17801        let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
17802            .into_iter()
17803            .partition(|owner| self.compatibility_owner_has_seen(&record, owner));
17804        if canonical_duplicate {
17805            if !owners.is_empty() {
17806                self.push_pending_record(SubscriberInputRecord {
17807                    record: record.clone(),
17808                    scope: SubscriberInputScope::OwnerOnly { owners },
17809                });
17810            }
17811            if !newly_served.is_empty() {
17812                for owner in &newly_served {
17813                    self.remember_compatibility_owner_record(&record, owner);
17814                }
17815                self.push_pending_record(SubscriberInputRecord {
17816                    record,
17817                    scope: SubscriberInputScope::OwnerOnlyHandlers {
17818                        owners: newly_served,
17819                    },
17820                });
17821            }
17822            return;
17823        }
17824        self.remember_record(&record);
17825        for owner in already_served.iter().chain(&newly_served) {
17826            self.remember_compatibility_owner_record(&record, owner);
17827        }
17828        self.push_pending_record(SubscriberInputRecord {
17829            record,
17830            scope: if already_served.is_empty() {
17831                SubscriberInputScope::Canonical { owners }
17832            } else {
17833                SubscriberInputScope::CanonicalResidual {
17834                    owners,
17835                    excluded: already_served,
17836                }
17837            },
17838        });
17839    }
17840
17841    fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
17842        let record = self.with_chain_id(record);
17843        if self.compatibility_owner_has_seen(&record, &owner) {
17844            return;
17845        }
17846        self.remember_compatibility_owner_record(&record, &owner);
17847        self.push_pending_record(SubscriberInputRecord {
17848            record,
17849            scope: SubscriberInputScope::OwnerOnlyHandlers {
17850                owners: vec![owner],
17851            },
17852        });
17853    }
17854
17855    fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
17856        self.owned_interests
17857            .iter()
17858            .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
17859            .filter(|entry| {
17860                entry
17861                    .interests
17862                    .iter()
17863                    .any(|interest| interest_matches(interest, &record.input))
17864            })
17865            .map(|entry| entry.owner.clone())
17866            .collect()
17867    }
17868
17869    fn compatibility_owner_has_seen(
17870        &self,
17871        record: &ReactiveInputRecord<N>,
17872        owner: &HandlerId,
17873    ) -> bool {
17874        should_dedupe_record(record)
17875            && self
17876                .recent_compat_owner_input_ref_sets
17877                .get(owner)
17878                .is_some_and(|seen| seen.contains(&record.input_ref()))
17879    }
17880
17881    fn remember_compatibility_owner_record(
17882        &mut self,
17883        record: &ReactiveInputRecord<N>,
17884        owner: &HandlerId,
17885    ) {
17886        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
17887            return;
17888        }
17889        let input_ref = record.input_ref();
17890        let seen = self
17891            .recent_compat_owner_input_ref_sets
17892            .entry(owner.clone())
17893            .or_default();
17894        if !seen.insert(input_ref) {
17895            return;
17896        }
17897        let recent = self
17898            .recent_compat_owner_input_refs
17899            .entry(owner.clone())
17900            .or_default();
17901        recent.push_back(input_ref);
17902        while recent.len() > self.config.reconnect.dedupe_window {
17903            if let Some(evicted) = recent.pop_front() {
17904                seen.remove(&evicted);
17905            }
17906        }
17907    }
17908
17909    fn enqueue_owner_record(
17910        &mut self,
17911        record: ReactiveInputRecord<N>,
17912        owner: SubscriberOwnerEpoch,
17913    ) {
17914        self.enqueue_owner_record_for_owners(record, vec![owner]);
17915    }
17916
17917    fn enqueue_owner_record_for_owners(
17918        &mut self,
17919        record: ReactiveInputRecord<N>,
17920        owners: Vec<SubscriberOwnerEpoch>,
17921    ) {
17922        self.enqueue_owner_record_for_owners_inner(record, owners, true);
17923    }
17924
17925    fn enqueue_owner_record_for_owners_unmerged(
17926        &mut self,
17927        record: ReactiveInputRecord<N>,
17928        owners: Vec<SubscriberOwnerEpoch>,
17929    ) {
17930        self.enqueue_owner_record_for_owners_inner(record, owners, false);
17931    }
17932
17933    fn enqueue_owner_record_for_owners_inner(
17934        &mut self,
17935        record: ReactiveInputRecord<N>,
17936        owners: Vec<SubscriberOwnerEpoch>,
17937        merge_pending: bool,
17938    ) {
17939        let record = self.with_chain_id(record);
17940        let owners = self.filter_recent_owner_duplicates(&record, owners);
17941        if owners.is_empty() {
17942            return;
17943        }
17944        if merge_pending
17945            && should_dedupe_record(&record)
17946            && self.config.reconnect.dedupe_window != 0
17947        {
17948            let input_ref = record.input_ref();
17949            if let Some(pending) = self
17950                .pending_records
17951                .iter_mut()
17952                .rev()
17953                .find(|pending| pending.record.input_ref() == input_ref)
17954            {
17955                let pending_owners = match &mut pending.scope {
17956                    SubscriberInputScope::Canonical { owners }
17957                    | SubscriberInputScope::CanonicalResidual { owners, .. }
17958                    | SubscriberInputScope::OwnerOnly { owners } => Some(owners),
17959                    SubscriberInputScope::OwnerOnlyHandlers { .. }
17960                    | SubscriberInputScope::Preconfirmed => None,
17961                };
17962                if let Some(pending_owners) = pending_owners {
17963                    for owner in owners {
17964                        if !pending_owners.contains(&owner) {
17965                            pending_owners.push(owner);
17966                        }
17967                    }
17968                    return;
17969                }
17970            }
17971        }
17972        self.push_pending_record(SubscriberInputRecord {
17973            record,
17974            scope: SubscriberInputScope::OwnerOnly { owners },
17975        });
17976    }
17977
17978    fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
17979        if self.pending_record_count() >= self.config.max_pending_records {
17980            self.note_resource_error(format!(
17981                "pending record queues reached the configured limit of {}",
17982                self.config.max_pending_records
17983            ));
17984            return;
17985        }
17986        self.pending_records.push_back(record);
17987    }
17988
17989    fn ensure_pending_record_capacity(
17990        &mut self,
17991        additional: usize,
17992        operation: &str,
17993    ) -> Result<(), SubscriberError> {
17994        let required = self.pending_record_count().saturating_add(additional);
17995        if required > self.config.max_pending_records {
17996            self.note_resource_error(format!(
17997                "{operation} require {required} pending records, above the configured limit of {}",
17998                self.config.max_pending_records
17999            ));
18000            return self.check_resource_error();
18001        }
18002        Ok(())
18003    }
18004
18005    fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
18006        if self.pending_record_count() >= self.config.max_pending_records {
18007            self.note_resource_error(format!(
18008                "pending record queues reached the configured limit of {}",
18009                self.config.max_pending_records
18010            ));
18011            return;
18012        }
18013        self.pending_reconcile_owner_records.push_back(record);
18014    }
18015
18016    fn pending_record_count(&self) -> usize {
18017        self.pending_records
18018            .len()
18019            .saturating_add(self.pending_reconcile_owner_records.len())
18020    }
18021
18022    fn note_resource_error(&mut self, message: String) {
18023        if self.resource_error.is_none() {
18024            self.resource_error = Some(message);
18025        }
18026    }
18027
18028    fn check_resource_error(&self) -> Result<(), SubscriberError> {
18029        match &self.resource_error {
18030            Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
18031            None => Ok(()),
18032        }
18033    }
18034
18035    fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
18036        record.context.chain_id = self.chain_id;
18037        if self.config.verify_log_block_context
18038            && let ReactiveInput::Log(log) = &record.input
18039            && !log.removed
18040            && let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
18041            && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
18042        {
18043            record.context.block = Some(verified);
18044            record.context.chain_status = ChainStatus::Included {
18045                block: verified,
18046                confirmations: 0,
18047            };
18048        }
18049        record
18050    }
18051
18052    fn staged_owners_for_record(
18053        &self,
18054        record: &ReactiveInputRecord<N>,
18055    ) -> Vec<SubscriberOwnerEpoch> {
18056        self.owned_interests
18057            .iter()
18058            .filter(|entry| entry.state == SubscriberOwnerState::Staged)
18059            .filter(|entry| {
18060                entry
18061                    .interests
18062                    .iter()
18063                    .any(|interest| interest_matches(interest, &record.input))
18064            })
18065            .filter_map(|entry| entry.epoch.clone())
18066            .collect()
18067    }
18068
18069    fn filter_recent_owner_duplicates(
18070        &mut self,
18071        record: &ReactiveInputRecord<N>,
18072        owners: Vec<SubscriberOwnerEpoch>,
18073    ) -> Vec<SubscriberOwnerEpoch> {
18074        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
18075            return owners;
18076        }
18077        let input_ref = record.input_ref();
18078        let window = self.config.reconnect.dedupe_window;
18079        owners
18080            .into_iter()
18081            .filter(|owner| {
18082                let seen = self
18083                    .recent_owner_input_ref_sets
18084                    .entry(owner.clone())
18085                    .or_default();
18086                if !seen.insert(input_ref) {
18087                    return false;
18088                }
18089                let recent = self
18090                    .recent_owner_input_refs
18091                    .entry(owner.clone())
18092                    .or_default();
18093                recent.push_back(input_ref);
18094                while recent.len() > window {
18095                    if let Some(evicted) = recent.pop_front() {
18096                        seen.remove(&evicted);
18097                    }
18098                }
18099                true
18100            })
18101            .collect()
18102    }
18103
18104    fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
18105        if !should_dedupe_record(record) {
18106            return false;
18107        }
18108        self.recent_input_ref_set.contains(&record.input_ref())
18109    }
18110
18111    fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
18112        if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
18113            return;
18114        }
18115
18116        let input_ref = record.input_ref();
18117        if !self.recent_input_ref_set.insert(input_ref) {
18118            return;
18119        }
18120        self.recent_input_refs.push_back(input_ref);
18121
18122        while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
18123            if let Some(evicted) = self.recent_input_refs.pop_front() {
18124                self.recent_input_ref_set.remove(&evicted);
18125            }
18126        }
18127    }
18128}
18129
18130fn stream_with_termination<N, S>(
18131    stream: S,
18132    source: SubscriberStreamSource,
18133) -> BoxStream<'static, SubscriberEvent<N>>
18134where
18135    N: Network + 'static,
18136    S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
18137{
18138    stream
18139        .chain(stream::once(async move {
18140            SubscriberEvent::StreamTerminated(source)
18141        }))
18142        .boxed()
18143}
18144
18145fn flashblock_reconnect_future<N>(
18146    provider: RootProvider<N>,
18147    source: SubscriberStreamSource,
18148    channel_size: usize,
18149    reconnect: SubscriberReconnectConfig,
18150    first_delay: Duration,
18151    flashblock_poll_interval: Duration,
18152) -> FlashblockReconnectFuture<N>
18153where
18154    N: Network + 'static,
18155{
18156    Box::pin(async move {
18157        if !reconnect.enabled {
18158            let error = SubscriberError::Provider(format!(
18159                "Alloy subscriber {} stream terminated and reconnect is disabled",
18160                source.label()
18161            ));
18162            return (source, Err(error));
18163        }
18164
18165        let mut attempts = 0_usize;
18166        let mut delay = first_delay;
18167        let mut retry_delay = reconnect.retry_delay;
18168        loop {
18169            attempts = attempts.saturating_add(1);
18170            if !delay.is_zero() {
18171                tokio::time::sleep(delay).await;
18172            }
18173            match connect_flashblock_source_once(
18174                &provider,
18175                source.clone(),
18176                channel_size,
18177                flashblock_poll_interval,
18178            )
18179            .await
18180            {
18181                Ok(stream) => return (source, Ok(stream)),
18182                Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
18183                    return (
18184                        source.clone(),
18185                        Err(SubscriberError::Provider(format!(
18186                            "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
18187                            source.label()
18188                        ))),
18189                    );
18190                }
18191                Err(error) => {
18192                    tracing::warn!(
18193                        stream = source.label(),
18194                        attempts,
18195                        error = %error,
18196                        "Flashblocks reconnect attempt failed"
18197                    );
18198                    delay = retry_delay;
18199                    retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
18200                }
18201            }
18202        }
18203    })
18204}
18205
18206async fn connect_flashblock_source_once<N>(
18207    provider: &RootProvider<N>,
18208    source: SubscriberStreamSource,
18209    channel_size: usize,
18210    flashblock_poll_interval: Duration,
18211) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
18212where
18213    N: Network + 'static,
18214{
18215    #[cfg(not(feature = "reactive-ws"))]
18216    let _ = provider;
18217
18218    match source {
18219        SubscriberStreamSource::BasePendingLog { id, filter } => {
18220            #[cfg(feature = "reactive-ws")]
18221            {
18222                let source = SubscriberStreamSource::BasePendingLog {
18223                    id,
18224                    filter: filter.clone(),
18225                };
18226                let params = base_pending_log_filter(&filter)?;
18227                let stream = provider
18228                    .subscribe::<_, Log>(("pendingLogs", params))
18229                    .channel_size(channel_size.max(1))
18230                    .await
18231                    .map_err(provider_error)?
18232                    .into_stream()
18233                    .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
18234                Ok(stream_with_termination(stream, source))
18235            }
18236            #[cfg(not(feature = "reactive-ws"))]
18237            {
18238                let _ = (id, filter, channel_size);
18239                Err(SubscriberError::Unsupported(
18240                    "Base Flashblocks require the reactive-ws feature",
18241                ))
18242            }
18243        }
18244        SubscriberStreamSource::BaseFlashblocks => {
18245            #[cfg(feature = "reactive-ws")]
18246            {
18247                let stream = provider
18248                    .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
18249                    .channel_size(channel_size.max(1))
18250                    .await
18251                    .map_err(provider_error)?
18252                    .into_stream()
18253                    .map(SubscriberEvent::BaseFlashblock);
18254                Ok(stream_with_termination(
18255                    stream,
18256                    SubscriberStreamSource::BaseFlashblocks,
18257                ))
18258            }
18259            #[cfg(not(feature = "reactive-ws"))]
18260            {
18261                let _ = channel_size;
18262                Err(SubscriberError::Unsupported(
18263                    "Base Flashblocks require the reactive-ws feature",
18264                ))
18265            }
18266        }
18267        SubscriberStreamSource::OpPendingFlashblocks => {
18268            let first_tick = tokio::time::Instant::now();
18269            let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
18270            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
18271            let stream = stream::unfold(interval, |mut interval| async move {
18272                interval.tick().await;
18273                Some((SubscriberEvent::OpFlashblockTick, interval))
18274            });
18275            Ok(stream_with_termination(
18276                stream,
18277                SubscriberStreamSource::OpPendingFlashblocks,
18278            ))
18279        }
18280        source => Err(SubscriberError::InvalidConfig(match source {
18281            SubscriberStreamSource::PubSubLog { .. }
18282            | SubscriberStreamSource::CanonicalHeadPolling
18283            | SubscriberStreamSource::PubSubPendingHashes
18284            | SubscriberStreamSource::PubSubBlockHeaders
18285            | SubscriberStreamSource::PollingLog { .. }
18286            | SubscriberStreamSource::PollingPendingHashes => {
18287                "Flashblocks reconnect received a canonical source"
18288            }
18289            SubscriberStreamSource::BasePendingLog { .. }
18290            | SubscriberStreamSource::BaseFlashblocks
18291            | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
18292            #[cfg(feature = "raw-flashblocks-json")]
18293            SubscriberStreamSource::ExternalFlashblockUpdates => {
18294                "Flashblocks reconnect cannot own an application-managed source"
18295            }
18296        })),
18297    }
18298}
18299
18300fn aggregate_interests<N: Network>(
18301    base: &[ReactiveInterest<N>],
18302    owned: &[OwnedSubscriberInterests<N>],
18303) -> Vec<ReactiveInterest<N>> {
18304    base.iter()
18305        .cloned()
18306        .chain(
18307            owned
18308                .iter()
18309                .flat_map(|entry| entry.interests.iter().cloned()),
18310        )
18311        .collect()
18312}
18313
18314fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
18315    SubscriberError::Provider(format!(
18316        "Alloy subscriber {} stream terminated before the subscriber was stopped",
18317        source.label()
18318    ))
18319}
18320
18321fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
18322    config
18323        .max_attempts
18324        .is_some_and(|max_attempts| attempts >= max_attempts)
18325}
18326
18327fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
18328    if current.is_zero() {
18329        return current;
18330    }
18331    current.checked_mul(2).unwrap_or(max).min(max)
18332}
18333
18334fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
18335    match &record.input {
18336        ReactiveInput::Log(log) => {
18337            is_canonical_status(&record.context.chain_status) && !log.removed
18338        }
18339        ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
18340        ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
18341    }
18342}
18343
18344#[cfg(test)]
18345mod subscriber_helper_tests {
18346    use super::*;
18347    use alloy_json_rpc::{RequestPacket, ResponsePacket};
18348    use alloy_provider::ProviderBuilder;
18349    use alloy_rpc_client::RpcClient;
18350    use alloy_transport::{TransportError, TransportFut, mock::Asserter};
18351    use std::task::{Context, Poll};
18352    use tower::Service;
18353
18354    #[derive(Clone, Debug)]
18355    struct NeverRespondingTransport;
18356
18357    impl Service<RequestPacket> for NeverRespondingTransport {
18358        type Response = ResponsePacket;
18359        type Error = TransportError;
18360        type Future = TransportFut<'static>;
18361
18362        fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
18363            Poll::Ready(Ok(()))
18364        }
18365
18366        fn call(&mut self, _request: RequestPacket) -> Self::Future {
18367            Box::pin(futures::future::pending())
18368        }
18369    }
18370
18371    fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
18372        BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
18373            payload_id: FixedBytes::repeat_byte(0x11),
18374            index: 0,
18375            base: Some(BaseFlashblockBase {
18376                parent_hash: B256::repeat_byte(100),
18377                block_number: 101,
18378                timestamp: 1_700_000_101,
18379                gas_limit: Some(30_000_000),
18380                base_fee_per_gas: Some(7),
18381                beneficiary: Some(Address::repeat_byte(0xcb)),
18382                prevrandao: Some(B256::repeat_byte(0x77)),
18383            }),
18384            diff: BaseFlashblockDiff {
18385                state_root,
18386                block_hash: B256::ZERO,
18387                transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
18388                transactions_root: None,
18389            },
18390            metadata: None,
18391        })
18392    }
18393
18394    #[test]
18395    fn duplicate_flashblock_transaction_membership_is_rejected() {
18396        let transaction = format!("{:#x}", B256::repeat_byte(0x41));
18397        let transactions = vec![
18398            serde_json::Value::String(transaction.clone()),
18399            serde_json::Value::String(transaction),
18400        ];
18401        assert!(matches!(
18402            flashblock_transaction_hashes(&transactions),
18403            Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
18404        ));
18405    }
18406
18407    #[test]
18408    fn conflicting_duplicate_indexed_flashblock_is_rejected() {
18409        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18410        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18411            provider,
18412            SubscriberMode::PubSub,
18413            SubscriberConfig::default(),
18414        )
18415        .with_provider_ref(ProviderRef::new("base-paid", 7));
18416        subscriber.chain_id = Some(8_453);
18417
18418        subscriber
18419            .accept_base_flashblock(indexed_flashblock(
18420                B256::repeat_byte(0x41),
18421                B256::repeat_byte(0xa1),
18422            ))
18423            .expect("first indexed preview");
18424        assert!(matches!(
18425            subscriber.accept_base_flashblock(indexed_flashblock(
18426                B256::repeat_byte(0x42),
18427                B256::repeat_byte(0xa2),
18428            )),
18429            Err(SubscriberError::Provider(ref message))
18430                if message.contains("conflicting duplicate")
18431        ));
18432    }
18433
18434    #[tokio::test]
18435    async fn duplicate_index_with_changed_commitment_is_rejected() {
18436        let transaction = B256::repeat_byte(0x41);
18437        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18438        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18439            provider,
18440            SubscriberMode::PubSub,
18441            SubscriberConfig::default(),
18442        )
18443        .with_provider_ref(ProviderRef::new("base-paid", 7));
18444        subscriber.chain_id = Some(8_453);
18445
18446        subscriber
18447            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
18448                transaction,
18449                B256::repeat_byte(0xa1),
18450            )))
18451            .await
18452            .expect("first indexed preview");
18453
18454        let BaseFlashblockWirePayload::Indexed(mut conflicting) =
18455            indexed_flashblock(transaction, B256::repeat_byte(0xa1))
18456        else {
18457            unreachable!()
18458        };
18459        conflicting.diff.state_root = B256::repeat_byte(0xbb);
18460        assert!(matches!(
18461            subscriber
18462                .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
18463                    BaseFlashblockWirePayload::Indexed(conflicting),
18464                ))
18465                .await,
18466            Err(SubscriberError::Provider(ref message))
18467                if message.contains("conflicting duplicate indexed Flashblock content")
18468        ));
18469    }
18470
18471    #[tokio::test]
18472    async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
18473        let transaction_a = B256::repeat_byte(0x41);
18474        let transaction_b = B256::repeat_byte(0x42);
18475        let transaction_c = B256::repeat_byte(0x43);
18476        let transaction_d = B256::repeat_byte(0x44);
18477        let asserter = Asserter::new();
18478        asserter.push_success(&100_u64);
18479        let pending = rpc_block(101, B256::ZERO).with_transactions(
18480            alloy_network::primitives::BlockTransactions::Hashes(vec![
18481                transaction_a,
18482                transaction_b,
18483                transaction_c,
18484            ]),
18485        );
18486        asserter.push_success(&Some(pending));
18487        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18488        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18489            provider,
18490            SubscriberMode::PubSub,
18491            SubscriberConfig::default(),
18492        )
18493        .with_provider_ref(ProviderRef::new("base-paid", 7));
18494        subscriber.chain_id = Some(8_453);
18495
18496        subscriber
18497            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
18498                transaction_a,
18499                B256::repeat_byte(0xa1),
18500            )))
18501            .await
18502            .expect("index zero preview");
18503        let BaseFlashblockWirePayload::Indexed(mut gap) =
18504            indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
18505        else {
18506            unreachable!()
18507        };
18508        gap.index = 2;
18509        gap.base = None;
18510        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18511        subscriber
18512            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
18513                BaseFlashblockWirePayload::Indexed(gap),
18514            ))
18515            .await
18516            .expect("the missing index is recovered from pending state");
18517
18518        let BaseFlashblockWirePayload::Indexed(mut next) =
18519            indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
18520        else {
18521            unreachable!()
18522        };
18523        next.index = 3;
18524        next.base = None;
18525        next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18526        let (next, recover) = subscriber
18527            .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
18528            .expect("the next diff extends the recovered cumulative set");
18529        assert!(!recover);
18530        assert_eq!(
18531            next.transaction_hashes,
18532            vec![transaction_a, transaction_b, transaction_c, transaction_d]
18533        );
18534    }
18535
18536    #[tokio::test]
18537    async fn unrecoverable_indexed_gap_revokes_the_generation() {
18538        let asserter = Asserter::new();
18539        asserter.push_success(&100_u64);
18540        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
18541        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
18542        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18543            provider,
18544            SubscriberMode::PubSub,
18545            SubscriberConfig {
18546                preconfirmations: PreconfirmationMode::Preferred,
18547                ..SubscriberConfig::default()
18548            },
18549        )
18550        .with_provider_ref(ProviderRef::new("base-paid", 7));
18551        subscriber.chain_id = Some(8_453);
18552
18553        subscriber
18554            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
18555                B256::repeat_byte(0x41),
18556                B256::repeat_byte(0xa1),
18557            )))
18558            .await
18559            .expect("index zero preview");
18560        let BaseFlashblockWirePayload::Indexed(mut gap) =
18561            indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
18562        else {
18563            unreachable!()
18564        };
18565        gap.index = 2;
18566        gap.base = None;
18567        gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
18568        let event = subscriber
18569            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
18570                BaseFlashblockWirePayload::Indexed(gap),
18571            ))
18572            .await
18573            .expect("preferred mode fails closed without pending recovery")
18574            .expect("generation invalidation is observable");
18575        assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
18576        assert!(subscriber.latest_preconfirmation.is_none());
18577        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
18578    }
18579
18580    #[test]
18581    fn base_flashblock_wire_decodes_cumulative_block_shape() {
18582        let payload: BaseFlashblockWirePayload = serde_json::from_str(
18583            r#"{
18584                "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
18585                "number":"0x2ef403b",
18586                "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18587                "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
18588                "timestamp":"0x6a68dd59",
18589                "transactions":[]
18590            }"#,
18591        )
18592        .expect("decode current Base newFlashblocks shape");
18593        let BaseFlashblockWirePayload::Block(payload) = payload else {
18594            panic!("expected cumulative block-shaped payload")
18595        };
18596        assert_eq!(payload.number, 49_233_979);
18597        assert_eq!(payload.timestamp, 1_785_257_305);
18598        assert_eq!(payload.hash, B256::repeat_byte(0xaa));
18599        assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
18600        assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
18601    }
18602
18603    #[tokio::test]
18604    async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
18605        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18606        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18607            provider,
18608            SubscriberMode::PubSub,
18609            SubscriberConfig::default(),
18610        )
18611        .with_provider_ref(ProviderRef::new("base-paid", 7));
18612        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
18613            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
18614            local_matcher: None,
18615            route_key: None,
18616        })];
18617        subscriber.interests = subscriber.base_interests.clone();
18618
18619        let first: BaseFlashblockWirePayload = serde_json::from_str(
18620            r#"{
18621                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
18622                "number":"0x65",
18623                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
18624                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
18625                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
18626                "timestamp":"0x6553f165",
18627                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
18628            }"#,
18629        )
18630        .expect("decode first cumulative preview");
18631        subscriber
18632            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(first))
18633            .await
18634            .expect("first preview is accepted");
18635
18636        let mut second_log = rpc_log(false);
18637        second_log.block_hash = Some(B256::ZERO);
18638        second_log.block_number = Some(102);
18639        second_log.block_timestamp = Some(1_700_000_102);
18640        second_log.transaction_hash = Some(B256::repeat_byte(0x42));
18641        second_log.transaction_index = Some(0);
18642        second_log.log_index = Some(0);
18643
18644        let before_preview = subscriber
18645            .normalize_flashblock_event(SubscriberEvent::BasePendingLog {
18646                source_id: 0,
18647                log: second_log,
18648            })
18649            .await
18650            .expect("a zero-hash log for the next block must be buffered");
18651        assert!(before_preview.is_none());
18652
18653        let second: BaseFlashblockWirePayload = serde_json::from_str(
18654            r#"{
18655                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
18656                "number":"0x66",
18657                "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
18658                "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18659                "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
18660                "timestamp":"0x6553f166",
18661                "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
18662            }"#,
18663        )
18664        .expect("decode second cumulative preview");
18665        let event = subscriber
18666            .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(second))
18667            .await
18668            .expect("second preview is accepted")
18669            .expect("the matching buffered log is released");
18670        let SubscriberEvent::PreconfirmedLogs { flashblock, logs } = event else {
18671            panic!("expected a preconfirmed log batch")
18672        };
18673        assert_eq!(flashblock.block_number, 102);
18674        assert_ne!(flashblock.content_hash, B256::ZERO);
18675        assert_eq!(flashblock.partial_block_hash, None);
18676        assert_eq!(logs.len(), 1);
18677        assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
18678        assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
18679    }
18680
18681    #[test]
18682    fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
18683        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18684        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18685            provider,
18686            SubscriberMode::PubSub,
18687            SubscriberConfig {
18688                preconfirmations: PreconfirmationMode::Required,
18689                ..SubscriberConfig::default()
18690            },
18691        )
18692        .with_provider_ref(ProviderRef::new("base-paid", 7));
18693        subscriber.chain_id = Some(8_453);
18694        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
18695
18696        let sources = subscriber.pubsub_stream_sources();
18697        assert!(
18698            sources
18699                .iter()
18700                .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
18701        );
18702        assert!(
18703            !sources
18704                .iter()
18705                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
18706        );
18707    }
18708
18709    #[test]
18710    #[cfg(feature = "raw-flashblocks-json")]
18711    fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() {
18712        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18713        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18714            provider,
18715            SubscriberMode::PubSub,
18716            SubscriberConfig {
18717                preconfirmations: PreconfirmationMode::Required,
18718                ..SubscriberConfig::default()
18719            },
18720        );
18721        subscriber
18722            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
18723            .expect("configure external source");
18724        subscriber.chain_id = Some(1);
18725        subscriber.base_interests = vec![
18726            ReactiveInterest::Blocks(BlockInterest::default()),
18727            log_interest_matching_rpc_log(),
18728        ];
18729        subscriber.interests = subscriber.base_interests.clone();
18730
18731        let pubsub = subscriber.pubsub_stream_sources();
18732        assert!(
18733            pubsub
18734                .iter()
18735                .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
18736        );
18737        assert!(
18738            pubsub
18739                .iter()
18740                .any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. }))
18741        );
18742        assert!(pubsub.iter().all(|source| !matches!(
18743            source,
18744            SubscriberStreamSource::BaseFlashblocks
18745                | SubscriberStreamSource::BasePendingLog { .. }
18746                | SubscriberStreamSource::OpPendingFlashblocks
18747                | SubscriberStreamSource::CanonicalHeadPolling
18748        )));
18749        assert!(
18750            subscriber
18751                .polling_stream_sources()
18752                .iter()
18753                .all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) })
18754        );
18755        assert!(
18756            subscriber
18757                .capabilities()
18758                .supports(SubscriberCapability::Preconfirmations)
18759        );
18760    }
18761
18762    #[test]
18763    #[cfg(feature = "raw-flashblocks-json")]
18764    fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() {
18765        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18766        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18767            provider,
18768            SubscriberMode::PubSub,
18769            SubscriberConfig::default(),
18770        );
18771        subscriber
18772            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
18773            .expect("configure external source");
18774        subscriber.chain_id = Some(1);
18775
18776        assert!(matches!(
18777            subscriber.validate_flashblocks_setup(),
18778            Err(SubscriberError::InvalidConfig(message))
18779                if message.contains("require preconfirmations")
18780        ));
18781    }
18782
18783    #[tokio::test]
18784    #[cfg(feature = "raw-flashblocks-json")]
18785    async fn external_flashblocks_configuration_is_rejected_after_registration_starts() {
18786        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18787        let mut fresh = AlloySubscriber::<_, Ethereum>::new(
18788            provider,
18789            SubscriberMode::PubSub,
18790            SubscriberConfig {
18791                preconfirmations: PreconfirmationMode::Preferred,
18792                ..SubscriberConfig::default()
18793            },
18794        );
18795        fresh
18796            .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
18797            .expect("construction-time external source");
18798
18799        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18800        let mut started = AlloySubscriber::<_, Ethereum>::new(
18801            provider,
18802            SubscriberMode::PubSub,
18803            SubscriberConfig {
18804                preconfirmations: PreconfirmationMode::Preferred,
18805                ..SubscriberConfig::default()
18806            },
18807        )
18808        .with_provider_ref(ProviderRef::new("canonical", 3));
18809        started.chain_id = Some(8_453);
18810        started
18811            .register_interests(&[log_interest_matching_rpc_log()])
18812            .await
18813            .expect("register canonical topology");
18814
18815        assert!(matches!(
18816            started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)),
18817            Err(SubscriberError::InvalidConfig(message))
18818                if message.contains("before subscriber registration")
18819        ));
18820    }
18821
18822    #[tokio::test]
18823    #[cfg(feature = "raw-flashblocks-json")]
18824    async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() {
18825        let asserter = Asserter::new();
18826        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
18827        let source = ProviderRef::new("raw-json", 4);
18828        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18829            provider,
18830            SubscriberMode::PubSub,
18831            SubscriberConfig {
18832                preconfirmations: PreconfirmationMode::Required,
18833                ..SubscriberConfig::default()
18834            },
18835        );
18836        subscriber
18837            .configure_external_flashblock_updates(source.clone())
18838            .expect("configure external source");
18839        subscriber.chain_id = Some(1);
18840        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18841        subscriber.interests = subscriber.base_interests.clone();
18842        let desired = subscriber.pubsub_stream_sources();
18843        let mut streams = SubscriberStreams::new();
18844        for source in desired {
18845            streams.push(source, stream::pending().boxed());
18846        }
18847        subscriber.state = AlloySubscriberState::Active(streams);
18848        subscriber.sources_dirty = false;
18849
18850        let preflight = subscriber
18851            .establish_flashblocks_preflight(1)
18852            .await
18853            .expect("external source preflight");
18854        assert_eq!(preflight.provider(), &source);
18855        assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates);
18856        assert_eq!(preflight.pending_log_subscriptions(), 0);
18857        assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0);
18858        assert!(asserter.read_q().is_empty());
18859    }
18860
18861    #[tokio::test]
18862    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
18863    async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream()
18864    {
18865        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
18866        let source = ProviderRef::new("raw-json", 4);
18867        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
18868            provider,
18869            SubscriberMode::PubSub,
18870            SubscriberConfig {
18871                preconfirmations: PreconfirmationMode::Preferred,
18872                ..SubscriberConfig::default()
18873            },
18874        );
18875        subscriber
18876            .configure_external_flashblock_updates(source.clone())
18877            .expect("configure external source");
18878        subscriber.chain_id = Some(1);
18879        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
18880        subscriber.interests = subscriber.base_interests.clone();
18881        let filter = subscriber.log_stream_filters().remove(0);
18882        let source_id = subscriber.log_source_id(&filter);
18883        let mut streams = SubscriberStreams::new();
18884        streams.push(
18885            SubscriberStreamSource::PubSubLog {
18886                id: source_id,
18887                filter,
18888            },
18889            stream::pending().boxed(),
18890        );
18891        subscriber.state = AlloySubscriberState::Active(streams);
18892        subscriber.sources_dirty = false;
18893
18894        let sender = subscriber
18895            .open_external_flashblock_update_channel(2)
18896            .expect("bounded external queue");
18897        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
18898        let update_stream = subscriber
18899            .connect_source_stream(external.clone())
18900            .await
18901            .expect("attach receiver as subscriber source");
18902        subscriber.install_source_stream(external, update_stream);
18903        subscriber.sources_dirty = false;
18904
18905        let mut adapter = RawJsonFlashblocksAdapter::new(source);
18906        let frame = br#"{
18907            "payload_id":"0x1111111111111111",
18908            "index":0,
18909            "base":{
18910                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
18911                "block_number":"0x7",
18912                "timestamp":"0x6553f107"
18913            },
18914            "diff":{
18915                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
18916                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
18917                "transactions":["0x01"]
18918            },
18919            "metadata":{
18920                "block_number":7,
18921                "receipts":{
18922                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
18923                        "logs":[{
18924                            "address":"0x4242424242424242424242424242424242424242",
18925                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
18926                            "data":"0x"
18927                        }]
18928                    }
18929                }
18930            }
18931        }"#;
18932        let update = adapter
18933            .ingest_json(frame)
18934            .expect("valid raw update")
18935            .expect("snapshot update");
18936        let valid_update = update.clone();
18937        let sending = {
18938            let sender = sender.clone();
18939            tokio::spawn(async move { sender.send(update).await })
18940        };
18941
18942        let preview = subscriber
18943            .next_scoped_batch()
18944            .await
18945            .expect("poll preview")
18946            .expect("preview batch");
18947        assert_eq!(preview.records().len(), 1);
18948        assert!(preview.records()[0].scope().is_preconfirmed());
18949        assert_eq!(
18950            preview.records()[0].context.source,
18951            InputSource::Flashblocks
18952        );
18953        assert!(subscriber.latest_preconfirmation.is_some());
18954        assert_eq!(sending.await.expect("sender task"), Ok(()));
18955
18956        let mut invalid_update = valid_update.clone();
18957        let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else {
18958            unreachable!("fixture is a snapshot")
18959        };
18960        snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee));
18961        let rejecting = {
18962            let sender = sender.clone();
18963            tokio::spawn(async move { sender.send(invalid_update).await })
18964        };
18965        let rejected = subscriber
18966            .next_scoped_batch()
18967            .await
18968            .expect("preferred mode keeps polling")
18969            .expect("rejected update invalidation");
18970        assert!(rejected.preconfirmation_invalidated());
18971        assert!(rejected.records().is_empty());
18972        assert!(subscriber.latest_preconfirmation.is_none());
18973        assert_eq!(
18974            rejecting.await.expect("sender task"),
18975            Err(FlashblockUpdateChannelError::Rejected)
18976        );
18977        subscriber
18978            .ingest_flashblock_update(valid_update)
18979            .expect("rejected generation is ignored thereafter");
18980        assert!(subscriber.latest_preconfirmation.is_none());
18981
18982        let _reset = adapter
18983            .reset(ProviderRef::new("raw-json", 5))
18984            .expect("advance rejected source generation");
18985        let recovered_update = adapter
18986            .ingest_json(frame)
18987            .expect("valid replacement generation")
18988            .expect("replacement snapshot update");
18989        let recovering = {
18990            let sender = sender.clone();
18991            tokio::spawn(async move { sender.send(recovered_update).await })
18992        };
18993        let recovered = subscriber
18994            .next_scoped_batch()
18995            .await
18996            .expect("poll replacement generation")
18997            .expect("replacement preview batch");
18998        assert_eq!(recovered.records().len(), 1);
18999        assert!(matches!(
19000            &recovered.records()[0].context.chain_status,
19001            ChainStatus::Preconfirmed { flashblock }
19002                if flashblock.provider == ProviderRef::new("raw-json", 5)
19003        ));
19004        assert_eq!(recovering.await.expect("sender task"), Ok(()));
19005
19006        drop(sender);
19007        let invalidation = subscriber
19008            .next_scoped_batch()
19009            .await
19010            .expect("poll channel closure")
19011            .expect("closure invalidation");
19012        assert!(invalidation.preconfirmation_invalidated());
19013        assert!(invalidation.records().is_empty());
19014        assert!(subscriber.latest_preconfirmation.is_none());
19015        assert!(matches!(
19016            &subscriber.state,
19017            AlloySubscriberState::Active(streams)
19018                if streams.entries.iter().any(|entry| matches!(
19019                    entry.source,
19020                    SubscriberStreamSource::PubSubLog { id, .. } if id == source_id
19021                ))
19022        ));
19023    }
19024
19025    #[tokio::test]
19026    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19027    async fn required_external_channel_closure_fails_the_subscriber_closed() {
19028        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19029        let source = ProviderRef::new("raw-json", 4);
19030        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19031            provider,
19032            SubscriberMode::PubSub,
19033            SubscriberConfig {
19034                preconfirmations: PreconfirmationMode::Required,
19035                ..SubscriberConfig::default()
19036            },
19037        );
19038        subscriber
19039            .configure_external_flashblock_updates(source)
19040            .expect("configure external source");
19041        subscriber.chain_id = Some(1);
19042        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19043        subscriber.interests = subscriber.base_interests.clone();
19044        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19045        subscriber.sources_dirty = false;
19046
19047        let sender = subscriber
19048            .open_external_flashblock_update_channel(1)
19049            .expect("bounded external queue");
19050        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19051        let update_stream = subscriber
19052            .connect_source_stream(external.clone())
19053            .await
19054            .expect("attach receiver as subscriber source");
19055        subscriber.install_source_stream(external, update_stream);
19056        subscriber.sources_dirty = false;
19057        drop(sender);
19058
19059        assert!(matches!(
19060            subscriber.next_scoped_batch().await,
19061            Err(SubscriberError::Provider(ref message))
19062                if message.contains("required external Flashblock update channel closed")
19063        ));
19064    }
19065
19066    #[tokio::test]
19067    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19068    async fn required_external_channel_rejects_a_queued_malformed_update() {
19069        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19070        let source = ProviderRef::new("raw-json", 4);
19071        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19072            provider,
19073            SubscriberMode::PubSub,
19074            SubscriberConfig {
19075                preconfirmations: PreconfirmationMode::Required,
19076                ..SubscriberConfig::default()
19077            },
19078        );
19079        subscriber
19080            .configure_external_flashblock_updates(source.clone())
19081            .expect("configure external source");
19082        subscriber.chain_id = Some(1);
19083        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19084        subscriber.interests = subscriber.base_interests.clone();
19085        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19086        subscriber.sources_dirty = false;
19087
19088        let sender = subscriber
19089            .open_external_flashblock_update_channel(1)
19090            .expect("bounded external queue");
19091        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19092        let update_stream = subscriber
19093            .connect_source_stream(external.clone())
19094            .await
19095            .expect("attach receiver as subscriber source");
19096        subscriber.install_source_stream(external, update_stream);
19097        subscriber.sources_dirty = false;
19098
19099        let mut adapter = RawJsonFlashblocksAdapter::new(source);
19100        let mut update = adapter
19101            .ingest_json(
19102                br#"{
19103                    "payload_id":"0x1111111111111111",
19104                    "index":0,
19105                    "base":{
19106                        "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
19107                        "block_number":"0x7",
19108                        "timestamp":"0x6553f107"
19109                    },
19110                    "diff":{
19111                        "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19112                        "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
19113                        "transactions":[]
19114                    },
19115                    "metadata":{"block_number":7,"receipts":{}}
19116                }"#,
19117            )
19118            .expect("valid raw frame")
19119            .expect("snapshot update");
19120        let FlashblockUpdate::Snapshot(snapshot) = &mut update else {
19121            unreachable!("fixture is a snapshot")
19122        };
19123        snapshot.flashblock.content_hash = B256::ZERO;
19124        let sending = tokio::spawn(async move { sender.send(update).await });
19125
19126        assert!(matches!(
19127            subscriber.next_scoped_batch().await,
19128            Err(SubscriberError::Provider(ref message))
19129                if message.contains("content commitment is invalid")
19130        ));
19131        assert_eq!(
19132            sending.await.expect("sender task"),
19133            Err(FlashblockUpdateChannelError::Rejected)
19134        );
19135    }
19136
19137    #[tokio::test]
19138    #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
19139    async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() {
19140        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19141        let source = ProviderRef::new("raw-json", 4);
19142        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19143            provider,
19144            SubscriberMode::PubSub,
19145            SubscriberConfig {
19146                preconfirmations: PreconfirmationMode::Preferred,
19147                max_pending_records: 1,
19148                ..SubscriberConfig::default()
19149            },
19150        );
19151        subscriber
19152            .configure_external_flashblock_updates(source.clone())
19153            .expect("configure external source");
19154        subscriber.chain_id = Some(1);
19155        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19156        subscriber.interests = subscriber.base_interests.clone();
19157        subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
19158        subscriber.sources_dirty = false;
19159
19160        let sender = subscriber
19161            .open_external_flashblock_update_channel(1)
19162            .expect("bounded external queue");
19163        let external = SubscriberStreamSource::ExternalFlashblockUpdates;
19164        let update_stream = subscriber
19165            .connect_source_stream(external.clone())
19166            .await
19167            .expect("attach receiver as subscriber source");
19168        subscriber.install_source_stream(external, update_stream);
19169        subscriber.sources_dirty = false;
19170
19171        let first_frame = br#"{
19172            "payload_id":"0x1111111111111111",
19173            "index":0,
19174            "base":{
19175                "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
19176                "block_number":"0x7",
19177                "timestamp":"0x6553f107"
19178            },
19179            "diff":{
19180                "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19181                "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
19182                "transactions":["0x01"]
19183            },
19184            "metadata":{
19185                "block_number":7,
19186                "receipts":{
19187                    "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
19188                        "logs":[{
19189                            "address":"0x4242424242424242424242424242424242424242",
19190                            "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
19191                            "data":"0x"
19192                        }]
19193                    }
19194                }
19195            }
19196        }"#;
19197        let second_frame = br#"{
19198            "payload_id":"0x1111111111111111",
19199            "index":1,
19200            "diff":{
19201                "state_root":"0xabababababababababababababababababababababababababababababababab",
19202                "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
19203                "transactions":["0x02"]
19204            },
19205            "metadata":{
19206                "block_number":7,
19207                "receipts":{
19208                    "0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{
19209                        "logs":[
19210                            {"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"},
19211                            {"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"}
19212                        ]
19213                    }
19214                }
19215            }
19216        }"#;
19217        let mut adapter = RawJsonFlashblocksAdapter::new(source);
19218        let first = adapter
19219            .ingest_json(first_frame)
19220            .expect("valid first frame")
19221            .expect("first snapshot");
19222        let first_send = {
19223            let sender = sender.clone();
19224            tokio::spawn(async move { sender.send(first).await })
19225        };
19226        let first_batch = subscriber
19227            .next_scoped_batch()
19228            .await
19229            .expect("poll first preview")
19230            .expect("first preview batch");
19231        assert_eq!(first_batch.records().len(), 1);
19232        assert_eq!(first_send.await.expect("sender task"), Ok(()));
19233
19234        let oversized = adapter
19235            .ingest_json(second_frame)
19236            .expect("valid oversized delta")
19237            .expect("oversized standardized snapshot");
19238        let rejected_send = {
19239            let sender = sender.clone();
19240            tokio::spawn(async move { sender.send(oversized).await })
19241        };
19242        let invalidation = subscriber
19243            .next_scoped_batch()
19244            .await
19245            .expect("poll capacity rejection")
19246            .expect("capacity invalidation batch");
19247        assert!(invalidation.preconfirmation_invalidated());
19248        assert_eq!(
19249            rejected_send.await.expect("sender task"),
19250            Err(FlashblockUpdateChannelError::Rejected)
19251        );
19252        assert_eq!(subscriber.rejected_external_flashblock_generation, None);
19253
19254        let _ = adapter
19255            .reset(ProviderRef::new("raw-json", 5))
19256            .expect("advance after local capacity rejection");
19257        let recovered = adapter
19258            .ingest_json(first_frame)
19259            .expect("valid recovered frame")
19260            .expect("recovered snapshot");
19261        let recovered_send = {
19262            let sender = sender.clone();
19263            tokio::spawn(async move { sender.send(recovered).await })
19264        };
19265        let recovered_batch = subscriber
19266            .next_scoped_batch()
19267            .await
19268            .expect("poll recovered generation")
19269            .expect("recovered preview batch");
19270        assert_eq!(recovered_batch.records().len(), 1);
19271        assert!(matches!(
19272            &recovered_batch.records()[0].context.chain_status,
19273            ChainStatus::Preconfirmed { flashblock }
19274                if flashblock.provider == ProviderRef::new("raw-json", 5)
19275        ));
19276        assert_eq!(recovered_send.await.expect("sender task"), Ok(()));
19277    }
19278
19279    #[tokio::test]
19280    async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
19281        let asserter = Asserter::new();
19282        let certified = rpc_block(101, B256::repeat_byte(0x65));
19283        asserter.push_success(&Some(certified.clone()));
19284        asserter.push_success(&Some(certified));
19285        asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
19286        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19287        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19288            provider,
19289            SubscriberMode::PubSub,
19290            SubscriberConfig::default(),
19291        );
19292
19293        assert!(matches!(
19294            subscriber
19295                .fetch_certified_canonical_head()
19296                .await
19297                .expect("first certified head"),
19298            Some(SubscriberEvent::BlockHeader(_))
19299        ));
19300        assert!(
19301            subscriber
19302                .fetch_certified_canonical_head()
19303                .await
19304                .expect("duplicate certified head")
19305                .is_none()
19306        );
19307        assert!(matches!(
19308            subscriber.fetch_certified_canonical_head().await,
19309            Err(SubscriberError::Provider(ref message))
19310                if message.contains("placeholder hash")
19311        ));
19312    }
19313
19314    #[tokio::test]
19315    async fn canonical_head_certification_times_out_a_silent_provider() {
19316        let provider =
19317            ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true));
19318        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19319            provider,
19320            SubscriberMode::PubSub,
19321            SubscriberConfig {
19322                preconfirmations: PreconfirmationMode::Required,
19323                canonical_head_request_timeout: Duration::from_millis(10),
19324                ..SubscriberConfig::default()
19325            },
19326        );
19327        subscriber.chain_id = Some(8_453);
19328
19329        let result = tokio::time::timeout(
19330            Duration::from_millis(100),
19331            subscriber.fetch_certified_canonical_head(),
19332        )
19333        .await
19334        .expect("subscriber must bound a silent provider request");
19335        assert!(matches!(
19336            result,
19337            Err(SubscriberError::Provider(ref message))
19338                if message.contains("canonical head certification timed out")
19339        ));
19340    }
19341
19342    #[tokio::test]
19343    async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
19344        let asserter = Asserter::new();
19345        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19346        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19347        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19348            provider,
19349            SubscriberMode::PubSub,
19350            SubscriberConfig {
19351                preconfirmations: PreconfirmationMode::Required,
19352                ..SubscriberConfig::default()
19353            },
19354        );
19355        subscriber.chain_id = Some(10);
19356        subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
19357
19358        let event = subscriber
19359            .fetch_certified_canonical_head()
19360            .await
19361            .expect("OP pending parent can be certified")
19362            .expect("the first certified parent is emitted");
19363        let SubscriberEvent::BlockHeader(header) = event else {
19364            panic!("expected a certified canonical block header")
19365        };
19366        assert_eq!(header.number(), 100);
19367        assert_eq!(header.hash, B256::repeat_byte(0x64));
19368        assert_eq!(
19369            subscriber
19370                .flashblocks_rpc_metrics()
19371                .pending_block_requests(),
19372            1
19373        );
19374        assert_eq!(
19375            subscriber
19376                .flashblocks_rpc_metrics()
19377                .canonical_head_requests(),
19378            1
19379        );
19380        assert!(asserter.read_q().is_empty());
19381    }
19382
19383    #[test]
19384    fn optimism_uses_one_bounded_pending_state_stream() {
19385        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19386        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19387            provider,
19388            SubscriberMode::PubSub,
19389            SubscriberConfig {
19390                preconfirmations: PreconfirmationMode::Required,
19391                ..SubscriberConfig::default()
19392            },
19393        )
19394        .with_provider_ref(ProviderRef::new("op-paid", 11));
19395        subscriber.chain_id = Some(10);
19396        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
19397            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
19398            local_matcher: None,
19399            route_key: None,
19400        })];
19401        subscriber.interests = subscriber.base_interests.clone();
19402
19403        let sources = subscriber.pubsub_stream_sources();
19404        assert_eq!(
19405            sources
19406                .iter()
19407                .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
19408                .count(),
19409            1
19410        );
19411        assert!(sources.iter().all(|source| !matches!(
19412            source,
19413            SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
19414        )));
19415    }
19416
19417    #[test]
19418    fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
19419        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
19420        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19421            provider,
19422            SubscriberMode::PubSub,
19423            SubscriberConfig {
19424                preconfirmations: PreconfirmationMode::Required,
19425                ..SubscriberConfig::default()
19426            },
19427        );
19428        subscriber.chain_id = Some(10);
19429        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19430        subscriber.interests = subscriber.base_interests.clone();
19431
19432        // At 250 ms, the sampler reserves 4 * (exact parent + pending block +
19433        // one filtered log request) = 12 methods. The remaining 28 exact
19434        // receipt methods stay below the configured 40-method ceiling.
19435        assert_eq!(
19436            subscriber.pending_receipt_requests_per_second_capacity(),
19437            28
19438        );
19439        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
19440
19441        subscriber
19442            .interests
19443            .push(ReactiveInterest::Blocks(BlockInterest::default()));
19444        assert_eq!(
19445            subscriber.pending_receipt_requests_per_second_capacity(),
19446            24
19447        );
19448        assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
19449        subscriber.interests.pop();
19450
19451        for _ in 0..4 {
19452            assert!(subscriber.reserve_flashblock_rpc_methods(3));
19453            assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
19454            assert!(subscriber.reserve_flashblock_rpc_methods(7));
19455        }
19456        assert!(!subscriber.reserve_flashblock_rpc_methods(1));
19457        subscriber.reset_flashblock_tracking();
19458        assert!(
19459            !subscriber.reserve_flashblock_rpc_methods(1),
19460            "a reconnect must not reset an endpoint's rolling quota window"
19461        );
19462    }
19463
19464    #[test]
19465    fn flashblocks_config_rejects_a_zero_rpc_budget() {
19466        let config = SubscriberConfig {
19467            preconfirmations: PreconfirmationMode::Required,
19468            max_flashblock_rpc_requests_per_second: 0,
19469            ..SubscriberConfig::default()
19470        };
19471
19472        assert!(matches!(
19473            validate_subscriber_config(&config),
19474            Err(SubscriberError::InvalidConfig(
19475                "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
19476            ))
19477        ));
19478    }
19479
19480    #[test]
19481    fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() {
19482        let config = SubscriberConfig {
19483            preconfirmations: PreconfirmationMode::Required,
19484            canonical_head_request_timeout: Duration::ZERO,
19485            ..SubscriberConfig::default()
19486        };
19487
19488        assert!(matches!(
19489            validate_subscriber_config(&config),
19490            Err(SubscriberError::InvalidConfig(
19491                "SubscriberConfig::canonical_head_request_timeout must be greater than zero"
19492            ))
19493        ));
19494    }
19495
19496    #[tokio::test]
19497    async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
19498        let asserter = Asserter::new();
19499        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
19500        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19501        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19502            provider,
19503            SubscriberMode::PubSub,
19504            SubscriberConfig {
19505                preconfirmations: PreconfirmationMode::Required,
19506                // Four ticks reserve three fixed methods each. Three remaining
19507                // methods cannot fund even one receipt on every tick.
19508                max_flashblock_rpc_requests_per_second: 15,
19509                ..SubscriberConfig::default()
19510            },
19511        )
19512        .with_provider_ref(ProviderRef::new("op-paid", 12));
19513        subscriber.chain_id = Some(10);
19514        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19515        subscriber.interests = subscriber.base_interests.clone();
19516        let desired = subscriber.pubsub_stream_sources();
19517        let mut streams = SubscriberStreams::new();
19518        for source in desired {
19519            streams.push(source, stream::pending().boxed());
19520        }
19521        subscriber.state = AlloySubscriberState::Active(streams);
19522        subscriber.sources_dirty = false;
19523        assert!(matches!(
19524            subscriber.establish_flashblocks_preflight(10).await,
19525            Err(SubscriberError::InvalidConfig(message))
19526                if message.contains("leaves no capacity for OP transaction receipts")
19527        ));
19528        assert!(asserter.read_q().is_empty());
19529    }
19530
19531    #[cfg(feature = "reactive-ws")]
19532    #[tokio::test]
19533    async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
19534        let asserter = Asserter::new();
19535        asserter.push_success(&serde_json::json!({"flashblocks": true}));
19536        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19537        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19538            provider,
19539            SubscriberMode::PubSub,
19540            SubscriberConfig {
19541                preconfirmations: PreconfirmationMode::Required,
19542                ..SubscriberConfig::default()
19543            },
19544        )
19545        .with_provider_ref(ProviderRef::new("base-paid", 7));
19546        subscriber.chain_id = Some(8_453);
19547        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19548        subscriber.interests = subscriber.base_interests.clone();
19549        let desired = subscriber.pubsub_stream_sources();
19550        let mut streams = SubscriberStreams::new();
19551        for source in desired {
19552            streams.push(source, stream::pending().boxed());
19553        }
19554        subscriber.state = AlloySubscriberState::Active(streams);
19555        subscriber.sources_dirty = false;
19556
19557        let preflight = subscriber
19558            .establish_flashblocks_preflight(8_453)
19559            .await
19560            .expect("preflight succeeds");
19561
19562        assert_eq!(preflight.chain_id(), 8_453);
19563        assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
19564        assert_eq!(
19565            preflight.delivery(),
19566            FlashblocksDelivery::NativeSubscriptions
19567        );
19568        assert_eq!(preflight.pending_log_subscriptions(), 1);
19569        assert_eq!(preflight.pending_log_filters(), 1);
19570        assert_eq!(
19571            preflight.advertised_capabilities(),
19572            Some(&serde_json::json!({"flashblocks": true}))
19573        );
19574    }
19575
19576    #[tokio::test]
19577    async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
19578        let asserter = Asserter::new();
19579        asserter.push_success(&serde_json::json!(["flashblocksv1"]));
19580        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19581        asserter.push_success(&Vec::<Log>::new());
19582        asserter.push_success(&serde_json::json!([]));
19583        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19584        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19585            provider,
19586            SubscriberMode::PubSub,
19587            SubscriberConfig {
19588                preconfirmations: PreconfirmationMode::Required,
19589                ..SubscriberConfig::default()
19590            },
19591        )
19592        .with_provider_ref(ProviderRef::new("op-paid", 12));
19593        subscriber.chain_id = Some(10);
19594        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19595        subscriber.interests = subscriber.base_interests.clone();
19596        let desired = subscriber.pubsub_stream_sources();
19597        let mut streams = SubscriberStreams::new();
19598        for source in desired {
19599            streams.push(source, stream::pending().boxed());
19600        }
19601        subscriber.state = AlloySubscriberState::Active(streams);
19602        subscriber.sources_dirty = false;
19603
19604        let preflight = subscriber
19605            .establish_flashblocks_preflight(10)
19606            .await
19607            .expect("Optimism pending-state preflight succeeds");
19608
19609        assert_eq!(preflight.chain_id(), 10);
19610        assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
19611        assert_eq!(
19612            preflight.delivery(),
19613            FlashblocksDelivery::PendingStatePolling
19614        );
19615        assert_eq!(preflight.pending_log_subscriptions(), 0);
19616        assert_eq!(preflight.pending_log_filters(), 1);
19617        assert_eq!(
19618            preflight.advertised_capabilities(),
19619            Some(&serde_json::json!(["flashblocksv1"]))
19620        );
19621        assert!(asserter.read_q().is_empty());
19622    }
19623
19624    #[test]
19625    fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
19626        let transaction_hash = B256::repeat_byte(0x7e);
19627        let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
19628            .expect("serialize pending block fixture");
19629        value["transactions"] = serde_json::json!([{
19630            "type": "0x7e",
19631            "hash": transaction_hash,
19632            "sourceHash": B256::repeat_byte(0x11),
19633            "from": Address::repeat_byte(0x22),
19634            "to": Address::repeat_byte(0x33)
19635        }]);
19636
19637        let block = normalize_op_pending_block::<Ethereum>(value)
19638            .expect("OP-specific transaction bodies are reduced to hashes");
19639
19640        assert_eq!(
19641            block.transactions().as_hashes(),
19642            Some(&[transaction_hash][..])
19643        );
19644    }
19645
19646    #[tokio::test]
19647    async fn optimism_sampler_does_not_retry_malformed_pending_content() {
19648        let asserter = Asserter::new();
19649        let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
19650            .expect("serialize pending block fixture");
19651        pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
19652        asserter.push_success(&Some(pending));
19653        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19654        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19655            provider,
19656            SubscriberMode::PubSub,
19657            SubscriberConfig {
19658                preconfirmations: PreconfirmationMode::Required,
19659                ..SubscriberConfig::default()
19660            },
19661        )
19662        .with_provider_ref(ProviderRef::new("op-paid", 12));
19663        subscriber.chain_id = Some(10);
19664
19665        let error = match subscriber
19666            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19667            .await
19668        {
19669            Err(error) => error,
19670            Ok(_) => panic!("malformed provider content must fail immediately"),
19671        };
19672        assert!(
19673            error
19674                .to_string()
19675                .contains("transaction is missing its hash")
19676        );
19677        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
19678        assert!(asserter.read_q().is_empty());
19679    }
19680
19681    #[tokio::test]
19682    async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
19683        let asserter = Asserter::new();
19684        queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
19685        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
19686        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19687            provider,
19688            SubscriberMode::PubSub,
19689            SubscriberConfig {
19690                preconfirmations: PreconfirmationMode::Required,
19691                ..SubscriberConfig::default()
19692            },
19693        )
19694        .with_provider_ref(ProviderRef::new("op-paid", 12));
19695        subscriber.chain_id = Some(10);
19696
19697        assert!(
19698            subscriber
19699                .fetch_pending_flashblock(None)
19700                .await
19701                .expect("the exact parent certifies the pending payload")
19702                .is_some()
19703        );
19704    }
19705
19706    #[tokio::test]
19707    async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
19708        let asserter = Asserter::new();
19709        asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
19710        asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
19711        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19712        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19713            provider,
19714            SubscriberMode::PubSub,
19715            SubscriberConfig {
19716                preconfirmations: PreconfirmationMode::Required,
19717                ..SubscriberConfig::default()
19718            },
19719        )
19720        .with_provider_ref(ProviderRef::new("op-paid", 12));
19721        subscriber.chain_id = Some(10);
19722
19723        assert!(matches!(
19724            subscriber.fetch_pending_flashblock(None).await,
19725            Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
19726                ref message
19727            ))) if message.contains("does not extend its exact certified parent")
19728        ));
19729        assert!(asserter.read_q().is_empty());
19730    }
19731
19732    #[tokio::test]
19733    async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
19734        let asserter = Asserter::new();
19735        let pending = rpc_block(101, B256::ZERO);
19736        queue_op_pending(&asserter, pending.clone());
19737        asserter.push_success(&Vec::<Log>::new());
19738        queue_op_pending(&asserter, pending);
19739        asserter.push_success(&Vec::<Log>::new());
19740        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19741        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19742            provider,
19743            SubscriberMode::PubSub,
19744            SubscriberConfig {
19745                preconfirmations: PreconfirmationMode::Required,
19746                ..SubscriberConfig::default()
19747            },
19748        )
19749        .with_provider_ref(ProviderRef::new("op-paid", 12));
19750        subscriber.chain_id = Some(10);
19751        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19752        subscriber.interests = subscriber.base_interests.clone();
19753
19754        assert!(
19755            subscriber
19756                .fetch_pending_flashblock(None)
19757                .await
19758                .expect("first cumulative pending view")
19759                .is_some()
19760        );
19761        assert!(
19762            subscriber
19763                .fetch_pending_flashblock(None)
19764                .await
19765                .expect("duplicate cumulative pending view")
19766                .is_none()
19767        );
19768
19769        assert_eq!(
19770            subscriber.flashblocks_rpc_metrics(),
19771            FlashblocksRpcMetrics {
19772                capability_requests: 0,
19773                provider_pair_chain_requests: 0,
19774                canonical_head_requests: 2,
19775                pending_block_requests: 2,
19776                pending_log_requests: 2,
19777                pending_receipt_requests: 0,
19778                pending_receipts_completed: 0,
19779                pending_receipts_unavailable: 0,
19780                failed_requests: 0,
19781                raced_samples: 0,
19782            }
19783        );
19784        assert!(asserter.read_q().is_empty());
19785    }
19786
19787    #[tokio::test]
19788    async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
19789        let asserter = Asserter::new();
19790        let transaction = B256::repeat_byte(0x42);
19791        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
19792            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
19793        );
19794        let mut log = rpc_log(false);
19795        log.block_number = Some(101);
19796        log.block_hash = Some(B256::repeat_byte(0xa2));
19797        log.transaction_hash = Some(transaction);
19798        log.transaction_index = Some(0);
19799        log.log_index = Some(0);
19800        queue_op_pending(&asserter, pending.clone());
19801        asserter.push_success(&Vec::<Log>::new());
19802        asserter.push_success(&serde_json::Value::Null);
19803        queue_op_pending(&asserter, pending);
19804        asserter.push_success(&vec![log]);
19805        asserter.push_success(&serde_json::Value::Null);
19806        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19807        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19808            provider,
19809            SubscriberMode::PubSub,
19810            SubscriberConfig {
19811                preconfirmations: PreconfirmationMode::Required,
19812                ..SubscriberConfig::default()
19813            },
19814        )
19815        .with_provider_ref(ProviderRef::new("op-paid", 12));
19816        subscriber.chain_id = Some(10);
19817        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19818        subscriber.interests = subscriber.base_interests.clone();
19819
19820        assert!(matches!(
19821            subscriber
19822                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19823                .await
19824                .expect("first pending view is coherent"),
19825            Some(SubscriberEvent::FlashblockObserved)
19826        ));
19827        assert!(matches!(
19828            subscriber
19829                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19830                .await
19831                .expect("the unchanged view is checked again for lagging logs"),
19832            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
19833        ));
19834        assert!(asserter.read_q().is_empty());
19835    }
19836
19837    #[tokio::test]
19838    async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
19839        let asserter = Asserter::new();
19840        let transaction = B256::repeat_byte(0x42);
19841        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
19842            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
19843        );
19844        let mut log = rpc_log(false);
19845        log.block_number = Some(101);
19846        log.block_hash = Some(B256::repeat_byte(0xa2));
19847        log.transaction_hash = Some(transaction);
19848        log.transaction_index = Some(0);
19849        log.log_index = Some(0);
19850        queue_op_pending(&asserter, pending);
19851        asserter.push_success(&Vec::<Log>::new());
19852        asserter.push_success(&serde_json::json!({
19853            "transactionHash": transaction,
19854            "logs": [log]
19855        }));
19856        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19857        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19858            provider,
19859            SubscriberMode::PubSub,
19860            SubscriberConfig {
19861                preconfirmations: PreconfirmationMode::Required,
19862                ..SubscriberConfig::default()
19863            },
19864        )
19865        .with_provider_ref(ProviderRef::new("op-paid", 12));
19866        subscriber.chain_id = Some(10);
19867        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19868        subscriber.interests = subscriber.base_interests.clone();
19869
19870        let event = subscriber
19871            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19872            .await
19873            .expect("pending receipt fallback succeeds");
19874        assert!(matches!(
19875            event,
19876            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
19877        ));
19878        assert_eq!(
19879            subscriber
19880                .flashblocks_rpc_metrics()
19881                .pending_receipt_requests(),
19882            1
19883        );
19884        assert!(asserter.read_q().is_empty());
19885    }
19886
19887    #[tokio::test]
19888    async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
19889        let asserter = Asserter::new();
19890        let transaction_a = B256::repeat_byte(0x41);
19891        let transaction_b = B256::repeat_byte(0x42);
19892        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
19893            alloy_network::primitives::BlockTransactions::Hashes(vec![
19894                transaction_a,
19895                transaction_b,
19896            ]),
19897        );
19898        let mut log = rpc_log(false);
19899        log.block_number = Some(101);
19900        log.transaction_hash = Some(transaction_b);
19901        log.transaction_index = Some(1);
19902        queue_op_pending(&asserter, pending.clone());
19903        asserter.push_success(&Vec::<Log>::new());
19904        asserter.push_success(&serde_json::json!({
19905            "transactionHash": transaction_a,
19906            "logs": []
19907        }));
19908        queue_op_pending(&asserter, pending);
19909        asserter.push_success(&Vec::<Log>::new());
19910        asserter.push_success(&serde_json::json!({
19911            "transactionHash": transaction_b,
19912            "logs": [log]
19913        }));
19914        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19915        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19916            provider,
19917            SubscriberMode::PubSub,
19918            SubscriberConfig {
19919                preconfirmations: PreconfirmationMode::Required,
19920                max_pending_transaction_receipts_per_tick: 1,
19921                ..SubscriberConfig::default()
19922            },
19923        )
19924        .with_provider_ref(ProviderRef::new("op-paid", 12));
19925        subscriber.chain_id = Some(10);
19926        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19927        subscriber.interests = subscriber.base_interests.clone();
19928
19929        assert!(matches!(
19930            subscriber
19931                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19932                .await
19933                .expect("the first bounded receipt is hydrated"),
19934            Some(SubscriberEvent::FlashblockObserved)
19935        ));
19936        assert!(matches!(
19937            subscriber
19938                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19939                .await
19940                .expect("the remaining receipt is hydrated on the next tick"),
19941            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
19942        ));
19943        assert_eq!(
19944            subscriber
19945                .flashblocks_rpc_metrics()
19946                .pending_receipt_requests(),
19947            2
19948        );
19949        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
19950        assert!(asserter.read_q().is_empty());
19951    }
19952
19953    #[tokio::test]
19954    async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
19955        let asserter = Asserter::new();
19956        let transaction_a = B256::repeat_byte(0x41);
19957        let transaction_b = B256::repeat_byte(0x42);
19958        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
19959            alloy_network::primitives::BlockTransactions::Hashes(vec![
19960                transaction_a,
19961                transaction_b,
19962            ]),
19963        );
19964        let mut log = rpc_log(false);
19965        log.block_number = Some(101);
19966        log.transaction_hash = Some(transaction_b);
19967        log.transaction_index = Some(1);
19968        queue_op_pending(&asserter, pending.clone());
19969        asserter.push_success(&Vec::<Log>::new());
19970        asserter.push_success(&serde_json::Value::Null);
19971        queue_op_pending(&asserter, pending);
19972        asserter.push_success(&Vec::<Log>::new());
19973        asserter.push_success(&serde_json::json!({
19974            "transactionHash": transaction_b,
19975            "logs": [log]
19976        }));
19977        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
19978        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
19979            provider,
19980            SubscriberMode::PubSub,
19981            SubscriberConfig {
19982                preconfirmations: PreconfirmationMode::Required,
19983                max_pending_transaction_receipts_per_tick: 1,
19984                ..SubscriberConfig::default()
19985            },
19986        )
19987        .with_provider_ref(ProviderRef::new("op-paid", 12));
19988        subscriber.chain_id = Some(10);
19989        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
19990        subscriber.interests = subscriber.base_interests.clone();
19991
19992        assert!(matches!(
19993            subscriber
19994                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
19995                .await
19996                .expect("the first null receipt remains retryable"),
19997            Some(SubscriberEvent::FlashblockObserved)
19998        ));
19999        assert!(matches!(
20000            subscriber
20001                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20002                .await
20003                .expect("the next unattempted receipt is not starved"),
20004            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20005        ));
20006        assert!(
20007            subscriber
20008                .preconfirmed_unavailable_receipts
20009                .contains(&transaction_a)
20010        );
20011        assert!(
20012            subscriber
20013                .preconfirmed_receipted_transactions
20014                .contains(&transaction_b)
20015        );
20016        assert!(asserter.read_q().is_empty());
20017    }
20018
20019    #[tokio::test]
20020    async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
20021        let asserter = Asserter::new();
20022        let transaction_a = B256::repeat_byte(0x41);
20023        let transaction_b = B256::repeat_byte(0x42);
20024        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20025            alloy_network::primitives::BlockTransactions::Hashes(vec![
20026                transaction_a,
20027                transaction_b,
20028            ]),
20029        );
20030        let mut log = rpc_log(false);
20031        log.block_number = Some(101);
20032        log.transaction_hash = Some(transaction_b);
20033        log.transaction_index = Some(1);
20034        queue_op_pending(&asserter, pending.clone());
20035        asserter.push_success(&Vec::<Log>::new());
20036        asserter.push_success(&serde_json::json!({
20037            "transactionHash": transaction_a,
20038            "logs": []
20039        }));
20040        asserter.push_failure_msg("receipt temporarily unavailable");
20041        queue_op_pending(&asserter, pending);
20042        asserter.push_success(&Vec::<Log>::new());
20043        asserter.push_success(&serde_json::json!({
20044            "transactionHash": transaction_a,
20045            "logs": []
20046        }));
20047        asserter.push_success(&serde_json::json!({
20048            "transactionHash": transaction_b,
20049            "logs": [log]
20050        }));
20051        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20052        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20053            provider,
20054            SubscriberMode::PubSub,
20055            SubscriberConfig {
20056                preconfirmations: PreconfirmationMode::Required,
20057                max_pending_transaction_receipts_per_tick: 2,
20058                max_consecutive_flashblock_poll_failures: 2,
20059                ..SubscriberConfig::default()
20060            },
20061        )
20062        .with_provider_ref(ProviderRef::new("op-paid", 12));
20063        subscriber.chain_id = Some(10);
20064        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20065        subscriber.interests = subscriber.base_interests.clone();
20066
20067        assert!(
20068            subscriber
20069                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20070                .await
20071                .expect("one failed receipt response remains retryable")
20072                .is_none()
20073        );
20074        assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
20075        assert!(matches!(
20076            subscriber
20077                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20078                .await
20079                .expect("the complete batch is retried transactionally"),
20080            Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
20081        ));
20082        assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
20083        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
20084        assert_eq!(
20085            subscriber
20086                .flashblocks_rpc_metrics()
20087                .pending_receipt_requests(),
20088            4
20089        );
20090        assert!(asserter.read_q().is_empty());
20091    }
20092
20093    #[tokio::test]
20094    async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
20095        let asserter = Asserter::new();
20096        let sampled_transaction = B256::repeat_byte(0x41);
20097        let advanced_transaction = B256::repeat_byte(0x42);
20098        let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20099            alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
20100        );
20101        let mut log = rpc_log(false);
20102        log.block_number = Some(101);
20103        log.transaction_hash = Some(advanced_transaction);
20104        queue_op_pending(&asserter, pending);
20105        asserter.push_success(&Vec::<Log>::new());
20106        asserter.push_success(&serde_json::json!({
20107            "transactionHash": advanced_transaction,
20108            "logs": [log]
20109        }));
20110        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20111        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20112            provider,
20113            SubscriberMode::PubSub,
20114            SubscriberConfig {
20115                preconfirmations: PreconfirmationMode::Required,
20116                ..SubscriberConfig::default()
20117            },
20118        )
20119        .with_provider_ref(ProviderRef::new("op-paid", 12));
20120        subscriber.chain_id = Some(10);
20121        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20122        subscriber.interests = subscriber.base_interests.clone();
20123
20124        let error = match subscriber
20125            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20126            .await
20127        {
20128            Err(error) => error,
20129            Ok(_) => panic!("a receipt for another transaction must fail closed"),
20130        };
20131        assert!(
20132            error
20133                .to_string()
20134                .contains("hash disagrees with its request")
20135        );
20136        assert!(asserter.read_q().is_empty());
20137    }
20138
20139    #[tokio::test]
20140    async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
20141        let asserter = Asserter::new();
20142        let transaction_a = B256::repeat_byte(0x41);
20143        let transaction_b = B256::repeat_byte(0x42);
20144        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20145            alloy_network::primitives::BlockTransactions::Hashes(vec![
20146                transaction_a,
20147                transaction_b,
20148            ]),
20149        );
20150        let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
20151            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
20152        );
20153        queue_op_pending(&asserter, first);
20154        asserter.push_success(&Vec::<Log>::new());
20155        asserter.push_success(&serde_json::Value::Null);
20156        asserter.push_success(&serde_json::Value::Null);
20157        queue_op_pending(&asserter, regressive.clone());
20158        queue_op_pending(&asserter, regressive);
20159        asserter.push_success(&Vec::<Log>::new());
20160        asserter.push_success(&serde_json::Value::Null);
20161        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20162        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20163            provider,
20164            SubscriberMode::PubSub,
20165            SubscriberConfig {
20166                preconfirmations: PreconfirmationMode::Required,
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!(
20176            subscriber
20177                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20178                .await
20179                .expect("first pending view is coherent")
20180                .is_some()
20181        );
20182        assert!(matches!(
20183            subscriber
20184                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20185                .await
20186                .expect("regression revokes instead of terminating the stream"),
20187            Some(SubscriberEvent::FlashblockInvalidated)
20188        ));
20189        assert!(subscriber.latest_preconfirmation.is_none());
20190        assert!(subscriber.pending_preconfirmation_invalidation);
20191        assert!(
20192            subscriber
20193                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20194                .await
20195                .expect("a later coherent view establishes a fresh snapshot")
20196                .is_some()
20197        );
20198        assert!(subscriber.latest_preconfirmation.is_some());
20199        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
20200        assert!(asserter.read_q().is_empty());
20201    }
20202
20203    #[tokio::test]
20204    async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
20205        let asserter = Asserter::new();
20206        let first = rpc_block(101, B256::repeat_byte(0xa1));
20207        let second = rpc_block(102, B256::repeat_byte(0xa2));
20208        queue_op_pending(&asserter, first);
20209        asserter.push_success(&Vec::<Log>::new());
20210        queue_op_pending(&asserter, second);
20211        asserter.push_success(&Vec::<Log>::new());
20212        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20213        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20214            provider,
20215            SubscriberMode::PubSub,
20216            SubscriberConfig {
20217                preconfirmations: PreconfirmationMode::Required,
20218                ..SubscriberConfig::default()
20219            },
20220        )
20221        .with_provider_ref(ProviderRef::new("op-paid", 12));
20222        subscriber.chain_id = Some(10);
20223        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20224        subscriber.interests = subscriber.base_interests.clone();
20225
20226        subscriber
20227            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20228            .await
20229            .expect("first quiet payload is observed");
20230        assert!(!subscriber.pending_preconfirmation_invalidation);
20231        subscriber
20232            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20233            .await
20234            .expect("replacement quiet payload is observed");
20235        assert!(subscriber.pending_preconfirmation_invalidation);
20236        assert_eq!(
20237            subscriber
20238                .latest_preconfirmation
20239                .as_ref()
20240                .map(|flashblock| flashblock.block_number),
20241            Some(102)
20242        );
20243        assert!(asserter.read_q().is_empty());
20244    }
20245
20246    #[tokio::test]
20247    async fn optimism_sampler_rejects_malformed_pending_receipts() {
20248        let asserter = Asserter::new();
20249        let transaction = B256::repeat_byte(0x42);
20250        let pending = rpc_block(101, B256::ZERO).with_transactions(
20251            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
20252        );
20253        queue_op_pending(&asserter, pending);
20254        asserter.push_success(&Vec::<Log>::new());
20255        asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
20256        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20257        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20258            provider,
20259            SubscriberMode::PubSub,
20260            SubscriberConfig {
20261                preconfirmations: PreconfirmationMode::Required,
20262                ..SubscriberConfig::default()
20263            },
20264        )
20265        .with_provider_ref(ProviderRef::new("op-paid", 12));
20266        subscriber.chain_id = Some(10);
20267        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20268        subscriber.interests = subscriber.base_interests.clone();
20269
20270        let error = match subscriber
20271            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20272            .await
20273        {
20274            Err(error) => error,
20275            Ok(_) => panic!("malformed receipt content must fail closed"),
20276        };
20277        assert!(error.to_string().contains("missing its log array"));
20278        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
20279        assert!(asserter.read_q().is_empty());
20280    }
20281
20282    #[tokio::test]
20283    async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
20284        let asserter = Asserter::new();
20285        let transaction_a = B256::repeat_byte(0x41);
20286        let transaction_b = B256::repeat_byte(0x42);
20287        let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
20288            alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
20289        );
20290        let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
20291            alloy_network::primitives::BlockTransactions::Hashes(vec![
20292                transaction_a,
20293                transaction_b,
20294            ]),
20295        );
20296        let mut log = rpc_log(false);
20297        log.block_number = Some(101);
20298        log.block_hash = Some(B256::repeat_byte(0xa2));
20299        log.transaction_hash = Some(transaction_b);
20300        log.transaction_index = Some(1);
20301        log.log_index = Some(0);
20302        queue_op_pending(&asserter, first);
20303        asserter.push_success(&vec![log.clone()]);
20304        asserter.push_success(&serde_json::Value::Null);
20305        queue_op_pending(&asserter, second);
20306        asserter.push_success(&vec![log.clone()]);
20307        asserter.push_success(&serde_json::Value::Null);
20308        asserter.push_success(&serde_json::json!({
20309            "transactionHash": transaction_b,
20310            "logs": [log]
20311        }));
20312        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20313        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20314            provider,
20315            SubscriberMode::PubSub,
20316            SubscriberConfig {
20317                preconfirmations: PreconfirmationMode::Required,
20318                ..SubscriberConfig::default()
20319            },
20320        )
20321        .with_provider_ref(ProviderRef::new("op-paid", 12));
20322        subscriber.chain_id = Some(10);
20323        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20324        subscriber.interests = subscriber.base_interests.clone();
20325
20326        assert!(
20327            subscriber
20328                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20329                .await
20330                .expect("a cross-request race remains retryable")
20331                .is_none()
20332        );
20333        assert!(
20334            subscriber
20335                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20336                .await
20337                .expect("the next coherent cumulative view is delivered")
20338                .is_some()
20339        );
20340        assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
20341        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
20342        assert!(asserter.read_q().is_empty());
20343    }
20344
20345    #[tokio::test]
20346    async fn optimism_sampler_uses_the_paired_pending_state_provider() {
20347        let stream_asserter = Asserter::new();
20348        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20349        let state_asserter = Asserter::new();
20350        queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
20351        state_asserter.push_success(&Vec::<Log>::new());
20352        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
20353        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20354            stream_provider,
20355            SubscriberMode::PubSub,
20356            SubscriberConfig {
20357                preconfirmations: PreconfirmationMode::Required,
20358                ..SubscriberConfig::default()
20359            },
20360        )
20361        .with_provider_ref(ProviderRef::new("op-paid", 12))
20362        .with_flashblocks_state_provider(state_provider);
20363        subscriber.chain_id = Some(10);
20364        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20365        subscriber.interests = subscriber.base_interests.clone();
20366
20367        assert!(
20368            subscriber
20369                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20370                .await
20371                .expect("paired pending-state reads succeed")
20372                .is_some()
20373        );
20374        assert!(state_asserter.read_q().is_empty());
20375        assert!(stream_asserter.read_q().is_empty());
20376    }
20377
20378    #[tokio::test]
20379    async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
20380        let asserter = Asserter::new();
20381        let pending = rpc_block(101, B256::ZERO);
20382        queue_op_pending(&asserter, pending.clone());
20383        asserter.push_failure_msg("temporarily unavailable");
20384        queue_op_pending(&asserter, pending);
20385        asserter.push_success(&Vec::<Log>::new());
20386        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20387        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20388            provider,
20389            SubscriberMode::PubSub,
20390            SubscriberConfig {
20391                preconfirmations: PreconfirmationMode::Required,
20392                max_consecutive_flashblock_poll_failures: 2,
20393                ..SubscriberConfig::default()
20394            },
20395        )
20396        .with_provider_ref(ProviderRef::new("op-paid", 12));
20397        subscriber.chain_id = Some(10);
20398        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20399        subscriber.interests = subscriber.base_interests.clone();
20400
20401        assert!(
20402            subscriber
20403                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20404                .await
20405                .expect("one request failure stays retryable")
20406                .is_none()
20407        );
20408        assert!(
20409            subscriber
20410                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20411                .await
20412                .expect("the next cumulative view retries the missing logs")
20413                .is_some()
20414        );
20415        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
20416        assert!(asserter.read_q().is_empty());
20417    }
20418
20419    #[tokio::test]
20420    async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
20421        let asserter = Asserter::new();
20422        asserter.push_failure_msg("temporarily unavailable");
20423        asserter.push_failure_msg("still unavailable");
20424        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
20425        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20426            provider,
20427            SubscriberMode::PubSub,
20428            SubscriberConfig {
20429                preconfirmations: PreconfirmationMode::Required,
20430                max_consecutive_flashblock_poll_failures: 2,
20431                ..SubscriberConfig::default()
20432            },
20433        )
20434        .with_provider_ref(ProviderRef::new("op-paid", 12));
20435        subscriber.chain_id = Some(10);
20436
20437        assert!(
20438            subscriber
20439                .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20440                .await
20441                .expect("the first request failure stays retryable")
20442                .is_none()
20443        );
20444        let error = match subscriber
20445            .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
20446            .await
20447        {
20448            Err(error) => error,
20449            Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
20450        };
20451        assert!(error.to_string().contains("still unavailable"));
20452        assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
20453        assert!(asserter.read_q().is_empty());
20454    }
20455
20456    #[tokio::test]
20457    async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
20458        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
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("wrong-chain", 1));
20468        subscriber.chain_id = Some(10);
20469        subscriber.interests = vec![log_interest_matching_rpc_log()];
20470
20471        assert!(matches!(
20472            subscriber.establish_flashblocks_preflight(8_453).await,
20473            Err(SubscriberError::ChainMismatch {
20474                expected: 8_453,
20475                actual: 10
20476            })
20477        ));
20478    }
20479
20480    #[tokio::test]
20481    async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
20482        let stream_asserter = Asserter::new();
20483        let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20484        let state_asserter = Asserter::new();
20485        state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
20486        state_asserter.push_success(&8_453_u64);
20487        let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
20488        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20489            stream_provider,
20490            SubscriberMode::PubSub,
20491            SubscriberConfig {
20492                preconfirmations: PreconfirmationMode::Required,
20493                ..SubscriberConfig::default()
20494            },
20495        )
20496        .with_provider_ref(ProviderRef::new("op-paid", 12))
20497        .with_flashblocks_state_provider(state_provider);
20498        subscriber.chain_id = Some(10);
20499        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
20500        subscriber.interests = subscriber.base_interests.clone();
20501        let desired = subscriber.pubsub_stream_sources();
20502        let mut streams = SubscriberStreams::new();
20503        for source in desired {
20504            streams.push(source, stream::pending().boxed());
20505        }
20506        subscriber.state = AlloySubscriberState::Active(streams);
20507        subscriber.sources_dirty = false;
20508        assert!(matches!(
20509            subscriber.establish_flashblocks_preflight(10).await,
20510            Err(SubscriberError::ChainMismatch {
20511                expected: 10,
20512                actual: 8_453
20513            })
20514        ));
20515        assert!(state_asserter.read_q().is_empty());
20516        assert!(stream_asserter.read_q().is_empty());
20517    }
20518
20519    #[test]
20520    fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
20521        let parent = BlockRef {
20522            number: 79,
20523            hash: B256::repeat_byte(0x79),
20524            parent_hash: Some(B256::repeat_byte(0x78)),
20525            timestamp: Some(1_700_000_079),
20526        };
20527        let old_tip = BlockRef {
20528            number: 80,
20529            hash: B256::repeat_byte(0x80),
20530            parent_hash: Some(parent.hash),
20531            timestamp: Some(1_700_000_080),
20532        };
20533        let replacement = BlockRef {
20534            hash: B256::repeat_byte(0xe0),
20535            parent_hash: Some(B256::repeat_byte(0xdf)),
20536            ..old_tip
20537        };
20538        let mut state =
20539            CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
20540
20541        let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
20542            .expect("replacement metadata is structurally valid")
20543            .expect("unknown parent is an observable rewind");
20544
20545        assert_eq!(rewind.common_ancestor, None);
20546        assert_eq!(rewind.dropped, vec![parent, old_tip]);
20547        assert_eq!(state.retained_canonical_history(), &[replacement]);
20548        assert_eq!(state.coverage_head(), Some(&replacement));
20549        assert_eq!(state.safe_head(), None);
20550        assert_eq!(state.finalized_head(), None);
20551    }
20552
20553    #[test]
20554    fn handler_ids_are_non_empty_across_construction_and_deserialization() {
20555        assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
20556        let valid = HandlerId::try_new("owner-1").expect("non-empty id");
20557        let encoded = serde_json::to_string(&valid).expect("serialize id");
20558        assert_eq!(
20559            serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
20560            valid
20561        );
20562        assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
20563    }
20564
20565    fn rpc_log(removed: bool) -> Log {
20566        Log {
20567            inner: alloy_primitives::Log::new_unchecked(
20568                Address::repeat_byte(0x42),
20569                vec![B256::repeat_byte(0x01)],
20570                Bytes::new(),
20571            ),
20572            block_hash: Some(B256::repeat_byte(0x02)),
20573            block_number: Some(7),
20574            block_timestamp: Some(1_700_000_000),
20575            transaction_hash: Some(B256::repeat_byte(0x03)),
20576            transaction_index: Some(4),
20577            log_index: Some(5),
20578            removed,
20579        }
20580    }
20581
20582    fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
20583        use alloy_consensus::SignableTransaction as _;
20584
20585        let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
20586            chain_id,
20587            ..Default::default()
20588        }
20589        .into_signed(alloy_primitives::Signature::test_signature())
20590        .into();
20591        alloy_rpc_types_eth::Transaction {
20592            inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
20593            block_hash: None,
20594            block_number: None,
20595            transaction_index: None,
20596            effective_gas_price: None,
20597        }
20598    }
20599
20600    #[cfg(feature = "reactive-ws")]
20601    fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log {
20602        Log {
20603            inner: alloy_primitives::Log::new_unchecked(
20604                Address::repeat_byte(0x42),
20605                vec![B256::repeat_byte(0x01)],
20606                Bytes::new(),
20607            ),
20608            block_hash: Some(B256::repeat_byte(block_number as u8)),
20609            block_number: Some(block_number),
20610            block_timestamp: Some(1_700_000_000 + block_number),
20611            transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
20612            transaction_index: Some(transaction_index),
20613            log_index: Some(log_index),
20614            removed: false,
20615        }
20616    }
20617
20618    #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
20619    fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
20620        alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
20621            hash,
20622            inner: alloy_consensus::Header {
20623                number,
20624                parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
20625                timestamp: 1_700_000_000 + number,
20626                ..Default::default()
20627            },
20628            total_difficulty: None,
20629            size: None,
20630        })
20631    }
20632
20633    fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
20634        let parent = rpc_block(
20635            pending.header().number().saturating_sub(1),
20636            pending.header().parent_hash(),
20637        );
20638        asserter.push_success(&Some(pending));
20639        asserter.push_success(&Some(parent));
20640    }
20641
20642    #[tokio::test(flavor = "multi_thread")]
20643    #[cfg(feature = "reactive-ws")]
20644    async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
20645        let stream_asserter = Asserter::new();
20646        let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
20647        let verification_asserter = Asserter::new();
20648        verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
20649        let verification_provider =
20650            ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
20651        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20652            provider,
20653            SubscriberMode::PubSub,
20654            SubscriberConfig {
20655                verify_log_block_context: true,
20656                ..SubscriberConfig::default()
20657            },
20658        )
20659        .with_log_verification_provider(verification_provider);
20660        let log = rpc_log_at(7, 0, 0);
20661
20662        subscriber
20663            .verify_log_block_context(&log)
20664            .await
20665            .expect("verify live log block");
20666        subscriber
20667            .verify_log_block_context(&log)
20668            .await
20669            .expect("reuse verified block cache");
20670        let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
20671
20672        assert_eq!(
20673            record.context.block.expect("verified block").parent_hash,
20674            Some(B256::repeat_byte(6))
20675        );
20676        assert!(
20677            verification_asserter.read_q().is_empty(),
20678            "one provider lookup should verify every log in the same block"
20679        );
20680        assert!(
20681            stream_asserter.read_q().is_empty(),
20682            "verification must not use the high-volume stream provider"
20683        );
20684    }
20685
20686    #[tokio::test(flavor = "multi_thread")]
20687    async fn stream_with_termination_yields_terminal_source_marker() {
20688        let mut stream = stream_with_termination::<Ethereum, _>(
20689            stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
20690                0xaa,
20691            ))]),
20692            SubscriberStreamSource::PubSubPendingHashes,
20693        );
20694
20695        assert!(matches!(
20696            stream.next().await,
20697            Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
20698        ));
20699        assert!(matches!(
20700            stream.next().await,
20701            Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
20702        ));
20703        assert!(stream.next().await.is_none());
20704    }
20705
20706    #[test]
20707    fn reconnect_delay_doubles_until_capped() {
20708        assert_eq!(
20709            next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
20710            Duration::from_millis(500)
20711        );
20712        assert_eq!(
20713            next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
20714            Duration::from_secs(1)
20715        );
20716        assert_eq!(
20717            next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
20718            Duration::ZERO
20719        );
20720    }
20721
20722    #[test]
20723    fn canonical_logs_are_deduped_but_removed_logs_are_not() {
20724        let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
20725        let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
20726
20727        assert!(should_dedupe_record(&included));
20728        assert!(!should_dedupe_record(&removed));
20729    }
20730
20731    #[test]
20732    fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
20733        let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
20734                                     timestamp: Option<u64>| {
20735            record.context.block.as_mut().expect("block").timestamp = timestamp;
20736            match &mut record.context.chain_status {
20737                ChainStatus::Included { block, .. }
20738                | ChainStatus::Safe { block }
20739                | ChainStatus::Finalized { block }
20740                | ChainStatus::Reorged {
20741                    dropped_from: block,
20742                } => block.timestamp = timestamp,
20743                ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
20744                    panic!("log record is canonical")
20745                }
20746            }
20747        };
20748
20749        let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
20750        let payload_timestamp = match &payload_only.input {
20751            ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
20752            _ => unreachable!(),
20753        };
20754        set_context_timestamp(&mut payload_only, None);
20755        let mut context_only = payload_only.clone();
20756        if let ReactiveInput::Log(log) = &mut context_only.input {
20757            log.block_timestamp = None;
20758        }
20759        set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
20760        assert!(matches!(
20761            dedupe_records(vec![payload_only, context_only]),
20762            Err(ReactiveError::InvalidInputRecord { .. })
20763        ));
20764
20765        let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
20766        if let ReactiveInput::Log(log) = &mut partial.input {
20767            log.block_timestamp = None;
20768        }
20769        set_context_timestamp(&mut partial, None);
20770        let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
20771        let deduped =
20772            dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
20773        assert_eq!(deduped.len(), 1);
20774        deduped[0]
20775            .validated_identity()
20776            .expect("merged record remains coherent");
20777        let resolved = resolve_record_block_payload_metadata(
20778            &deduped[0],
20779            *canonical_record_block(&deduped[0]).expect("canonical"),
20780        )
20781        .expect("effective block");
20782        assert_eq!(resolved.timestamp, Some(payload_timestamp));
20783    }
20784
20785    #[test]
20786    fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
20787        use alloy_rpc_types_eth::{Block, Header};
20788
20789        let block_ref = BlockRef {
20790            number: 7,
20791            hash: B256::repeat_byte(0x77),
20792            parent_hash: Some(B256::repeat_byte(0x66)),
20793            timestamp: Some(1_700_000_007),
20794        };
20795        let block = Block::empty(Header {
20796            hash: block_ref.hash,
20797            inner: alloy_consensus::Header {
20798                number: block_ref.number,
20799                parent_hash: block_ref.parent_hash.expect("parent"),
20800                timestamp: block_ref.timestamp.expect("timestamp"),
20801                ..Default::default()
20802            },
20803            total_difficulty: None,
20804            size: None,
20805        });
20806        let record = ReactiveInputRecord::<Ethereum>::new(
20807            ReactiveInput::FullBlock(block),
20808            ReactiveContext {
20809                chain_id: Some(1),
20810                source: InputSource::Subscription,
20811                chain_status: ChainStatus::Included {
20812                    block: block_ref,
20813                    confirmations: 0,
20814                },
20815                block: Some(block_ref),
20816                transaction_index: None,
20817                log_index: None,
20818            },
20819        );
20820
20821        assert!(!record.is_payload_deduplicable());
20822        assert!(!record.same_deduplicable_payload(&record));
20823        let retained = dedupe_scoped_records(vec![
20824            (
20825                record.clone(),
20826                DeliveryAudience::All,
20827                DeliveryScope::Canonical,
20828            ),
20829            (record, DeliveryAudience::All, DeliveryScope::Canonical),
20830        ])
20831        .expect("non-deduplicable bodies are preserved, not treated as conflicts");
20832        assert_eq!(retained.len(), 2);
20833    }
20834
20835    #[test]
20836    fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
20837        let pending_context = ReactiveContext {
20838            chain_id: Some(1),
20839            source: InputSource::Batch,
20840            chain_status: ChainStatus::Pending,
20841            block: None,
20842            transaction_index: None,
20843            log_index: None,
20844        };
20845        let mut included_pending = rpc_transaction(Some(1));
20846        included_pending.block_hash = Some(B256::repeat_byte(0xaa));
20847        assert!(matches!(
20848            ReactiveInputRecord::<Ethereum>::new(
20849                ReactiveInput::PendingTx(included_pending),
20850                pending_context.clone(),
20851            )
20852            .validated_identity(),
20853            Err(ReactiveError::InvalidInputRecord { .. })
20854        ));
20855        assert!(matches!(
20856            ReactiveInputRecord::<Ethereum>::new(
20857                ReactiveInput::PendingTx(rpc_transaction(Some(2))),
20858                pending_context,
20859            )
20860            .validated_identity(),
20861            Err(ReactiveError::InvalidInputRecord { .. })
20862        ));
20863
20864        let block_ref = BlockRef {
20865            number: 8,
20866            hash: B256::repeat_byte(0x88),
20867            parent_hash: Some(B256::repeat_byte(0x77)),
20868            timestamp: Some(1_700_000_008),
20869        };
20870        let header = alloy_rpc_types_eth::Header {
20871            hash: block_ref.hash,
20872            inner: alloy_consensus::Header {
20873                number: block_ref.number,
20874                parent_hash: block_ref.parent_hash.expect("parent"),
20875                timestamp: block_ref.timestamp.expect("timestamp"),
20876                ..Default::default()
20877            },
20878            total_difficulty: None,
20879            size: None,
20880        };
20881        let context = ReactiveContext {
20882            chain_id: Some(1),
20883            source: InputSource::Batch,
20884            chain_status: ChainStatus::Included {
20885                block: block_ref,
20886                confirmations: 0,
20887            },
20888            block: Some(block_ref),
20889            transaction_index: None,
20890            log_index: None,
20891        };
20892        for transaction in [
20893            alloy_rpc_types_eth::Transaction {
20894                block_hash: Some(B256::repeat_byte(0xff)),
20895                ..rpc_transaction(Some(1))
20896            },
20897            alloy_rpc_types_eth::Transaction {
20898                block_hash: Some(block_ref.hash),
20899                block_number: Some(block_ref.number),
20900                transaction_index: Some(1),
20901                ..rpc_transaction(Some(1))
20902            },
20903            rpc_transaction(Some(2)),
20904        ] {
20905            let block = alloy_rpc_types_eth::Block::new(
20906                header.clone(),
20907                alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
20908            );
20909            assert!(matches!(
20910                ReactiveInputRecord::<Ethereum>::new(
20911                    ReactiveInput::FullBlock(block),
20912                    context.clone(),
20913                )
20914                .validated_identity(),
20915                Err(ReactiveError::InvalidInputRecord { .. })
20916            ));
20917        }
20918    }
20919
20920    #[test]
20921    fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
20922        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20923        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20924            provider,
20925            SubscriberMode::Auto,
20926            SubscriberConfig::default(),
20927        );
20928        let owner = HandlerId::new("compat-owner");
20929        subscriber
20930            .add_interest_owner(
20931                owner.clone(),
20932                &[ReactiveInterest::Logs(LogInterest {
20933                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
20934                    local_matcher: None,
20935                    route_key: None,
20936                })],
20937            )
20938            .unwrap();
20939        let log = rpc_log(false);
20940
20941        subscriber.enqueue_compat_owner_record(
20942            log_input_record(log.clone(), InputSource::Backfill),
20943            owner.clone(),
20944        );
20945        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
20946
20947        let batch = subscriber
20948            .drain_next_scoped_batch()
20949            .expect("owner catch-up and residual live copies");
20950        assert_eq!(batch.records.len(), 2);
20951        assert_eq!(
20952            batch.records[0].scope,
20953            SubscriberInputScope::OwnerOnlyHandlers {
20954                owners: vec![owner.clone()]
20955            }
20956        );
20957        assert_eq!(
20958            batch.records[1].scope,
20959            SubscriberInputScope::CanonicalResidual {
20960                owners: Vec::new(),
20961                excluded: vec![owner.clone()]
20962            }
20963        );
20964
20965        let reactive = batch.into_reactive_batch();
20966        assert_eq!(
20967            reactive.record_audience(0),
20968            Some(&DeliveryAudience::Owners(vec![owner.clone()]))
20969        );
20970        assert_eq!(
20971            reactive.record_delivery_scope(0),
20972            Some(DeliveryScope::OwnerCatchup)
20973        );
20974        assert_eq!(
20975            reactive.record_audience(1),
20976            Some(&DeliveryAudience::AllExcept(vec![owner]))
20977        );
20978        assert_eq!(
20979            reactive.record_delivery_scope(1),
20980            Some(DeliveryScope::Canonical)
20981        );
20982    }
20983
20984    #[test]
20985    fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
20986        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
20987        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
20988            provider,
20989            SubscriberMode::Auto,
20990            SubscriberConfig::default(),
20991        );
20992        let owner = HandlerId::new("replace-owner");
20993        let original = ReactiveInterest::Logs(LogInterest {
20994            provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
20995            local_matcher: None,
20996            route_key: None,
20997        });
20998        let replacement_interest = ReactiveInterest::Logs(LogInterest {
20999            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21000            local_matcher: None,
21001            route_key: None,
21002        });
21003        let active = subscriber
21004            .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
21005            .unwrap();
21006        assert!(subscriber.activate_interest_owner(&active));
21007        let replacement = subscriber
21008            .stage_interest_owner_replacement(
21009                owner,
21010                &[replacement_interest],
21011                SubscriberOwnerStart::Live,
21012            )
21013            .unwrap();
21014
21015        assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
21016        assert_eq!(subscriber.interest_owner_state(&active), None);
21017        assert_eq!(
21018            subscriber.interest_owner_state(&replacement),
21019            Some(SubscriberOwnerState::Active)
21020        );
21021        assert_eq!(subscriber.registered_interests().len(), 1);
21022    }
21023
21024    #[test]
21025    fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
21026        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21027        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21028            provider,
21029            SubscriberMode::Auto,
21030            SubscriberConfig::default(),
21031        );
21032        let owner = HandlerId::new("one-lifecycle");
21033        let interest = ReactiveInterest::Logs(LogInterest {
21034            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21035            local_matcher: None,
21036            route_key: None,
21037        });
21038        let epoch = subscriber
21039            .stage_interest_owner(
21040                owner.clone(),
21041                std::slice::from_ref(&interest),
21042                SubscriberOwnerStart::Live,
21043            )
21044            .expect("stage epoch owner");
21045
21046        assert!(matches!(
21047            subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
21048            Err(SubscriberError::InvalidConfig(_))
21049        ));
21050        assert_eq!(
21051            subscriber.interest_owner_state(&epoch),
21052            Some(SubscriberOwnerState::Staged)
21053        );
21054        assert!(subscriber.abort_interest_owner(&epoch));
21055        subscriber
21056            .add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
21057            .expect("compatibility owner after epoch abort");
21058        assert!(matches!(
21059            subscriber.stage_interest_owner_replacement(
21060                owner,
21061                std::slice::from_ref(&interest),
21062                SubscriberOwnerStart::Live,
21063            ),
21064            Err(SubscriberOwnerError::AlreadyRegistered(_))
21065        ));
21066    }
21067
21068    #[test]
21069    fn pending_record_overflow_is_sticky_and_fail_closed() {
21070        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21071        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21072            provider,
21073            SubscriberMode::Polling,
21074            SubscriberConfig {
21075                max_pending_records: 1,
21076                ..SubscriberConfig::default()
21077            },
21078        );
21079        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
21080            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21081            local_matcher: None,
21082            route_key: None,
21083        })];
21084        subscriber.enqueue_event(SubscriberEvent::Log {
21085            source_id: 0,
21086            log: rpc_log(false),
21087        });
21088        let mut second = rpc_log(false);
21089        second.log_index = Some(6);
21090        second.transaction_hash = Some(B256::repeat_byte(0x04));
21091        subscriber.enqueue_event(SubscriberEvent::Log {
21092            source_id: 0,
21093            log: second,
21094        });
21095
21096        assert_eq!(subscriber.pending_records.len(), 1);
21097        assert!(matches!(
21098            subscriber.check_resource_error(),
21099            Err(SubscriberError::ResourceExhausted(_))
21100        ));
21101        subscriber.reset_delivery_state();
21102        assert!(subscriber.check_resource_error().is_ok());
21103    }
21104
21105    #[test]
21106    fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
21107        let baseline = rpc_log(false);
21108        let fixed_bytes =
21109            validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
21110                .expect("measure fixed log accounting");
21111        let mut large = baseline;
21112        large.inner = alloy_primitives::Log::new_unchecked(
21113            Address::repeat_byte(0x42),
21114            vec![B256::repeat_byte(0x01)],
21115            Bytes::from(vec![0u8; 256]),
21116        );
21117
21118        assert!(matches!(
21119            validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
21120            Err(SubscriberError::ResourceExhausted(_))
21121        ));
21122    }
21123
21124    #[tokio::test(flavor = "multi_thread")]
21125    #[cfg(feature = "reactive-polling")]
21126    async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
21127        use alloy_rpc_types_eth::{Block, Header};
21128
21129        let asserter = Asserter::new();
21130        let baseline = BlockRef {
21131            number: 6,
21132            hash: B256::repeat_byte(6),
21133            parent_hash: Some(B256::repeat_byte(5)),
21134            timestamp: Some(1_700_000_006),
21135        };
21136        let through = BlockRef {
21137            number: 7,
21138            hash: B256::repeat_byte(7),
21139            parent_hash: Some(baseline.hash),
21140            timestamp: Some(1_700_000_007),
21141        };
21142        let rpc_block = || -> Block {
21143            Block::empty(Header {
21144                hash: through.hash,
21145                inner: alloy_consensus::Header {
21146                    number: through.number,
21147                    parent_hash: through.parent_hash.expect("parent"),
21148                    timestamp: through.timestamp.expect("timestamp"),
21149                    ..Default::default()
21150                },
21151                total_difficulty: None,
21152                size: None,
21153            })
21154        };
21155        let mut historical = rpc_log(false);
21156        historical.block_hash = Some(through.hash);
21157        historical.block_timestamp = through.timestamp;
21158        asserter.push_success(&Some(rpc_block()));
21159        asserter.push_success(&vec![historical]);
21160        asserter.push_success(&Some(rpc_block()));
21161        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21162        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21163            provider,
21164            SubscriberMode::Polling,
21165            SubscriberConfig {
21166                max_pending_records: 1,
21167                ..SubscriberConfig::default()
21168            },
21169        );
21170        subscriber.chain_id = Some(1);
21171        let interest = ReactiveInterest::Logs(LogInterest {
21172            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21173            local_matcher: None,
21174            route_key: None,
21175        });
21176        let epoch = subscriber
21177            .stage_interest_owner(
21178                HandlerId::new("capacity-owner"),
21179                std::slice::from_ref(&interest),
21180                SubscriberOwnerStart::PostBlock(baseline),
21181            )
21182            .expect("stage owner");
21183        // Isolate the commit-side capacity edge: the live queue acquired one
21184        // canonical record while the historical request was in flight.
21185        subscriber.sources_dirty = false;
21186        subscriber.state = AlloySubscriberState::Empty;
21187        subscriber.push_pending_record(SubscriberInputRecord {
21188            record: log_input_record(rpc_log(false), InputSource::Poll),
21189            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
21190        });
21191
21192        let error = subscriber
21193            .reconcile_interest_owner(&epoch, through)
21194            .await
21195            .expect_err("historical delivery cannot displace the queued live record");
21196        assert!(matches!(
21197            error,
21198            SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
21199        ));
21200        assert!(subscriber.interest_owner_progress(&epoch).is_none());
21201        assert_eq!(subscriber.pending_records.len(), 1);
21202        assert!(matches!(
21203            subscriber.pending_records[0].scope,
21204            SubscriberInputScope::Canonical { .. }
21205        ));
21206    }
21207
21208    #[test]
21209    fn lazy_backfill_queue_capacity_failure_is_atomic() {
21210        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21211        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21212            provider,
21213            SubscriberMode::Auto,
21214            SubscriberConfig {
21215                max_pending_backfills: 1,
21216                ..SubscriberConfig::default()
21217            },
21218        );
21219        let interest = |address| {
21220            ReactiveInterest::Logs(LogInterest {
21221                provider_filter: Filter::new().address(address),
21222                local_matcher: None,
21223                route_key: None,
21224            })
21225        };
21226        subscriber
21227            .add_interest_owner_with_backfill(
21228                HandlerId::new("owner-a"),
21229                &[interest(Address::repeat_byte(0x41))],
21230                SubscriberBackfill::from_block(10),
21231            )
21232            .expect("first queued backfill");
21233
21234        let error = subscriber
21235            .add_interest_owner_with_backfill(
21236                HandlerId::new("owner-b"),
21237                &[interest(Address::repeat_byte(0x42))],
21238                SubscriberBackfill::from_block(10),
21239            )
21240            .expect_err("second backfill must exceed capacity");
21241
21242        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
21243        assert!(
21244            subscriber
21245                .owner_interests(&HandlerId::new("owner-b"))
21246                .is_none()
21247        );
21248        assert_eq!(subscriber.pending_backfills.len(), 1);
21249    }
21250
21251    #[test]
21252    fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
21253        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21254        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21255            provider,
21256            SubscriberMode::Auto,
21257            SubscriberConfig {
21258                max_pending_backfills: 1,
21259                ..SubscriberConfig::default()
21260            },
21261        );
21262        let interest = |address| {
21263            ReactiveInterest::Logs(LogInterest {
21264                provider_filter: Filter::new().address(address),
21265                local_matcher: None,
21266                route_key: None,
21267            })
21268        };
21269        subscriber
21270            .add_interest_owner(
21271                HandlerId::new("crash-stale"),
21272                &[interest(Address::repeat_byte(0xee))],
21273            )
21274            .expect("seed stale owner");
21275        subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
21276        subscriber.rebuild_registered_interests();
21277        subscriber.push_pending_record(SubscriberInputRecord {
21278            record: log_input_record(rpc_log(false), InputSource::Poll),
21279            scope: SubscriberInputScope::Canonical { owners: Vec::new() },
21280        });
21281        let baseline = BlockRef {
21282            number: 100,
21283            hash: B256::repeat_byte(100),
21284            parent_hash: Some(B256::repeat_byte(99)),
21285            timestamp: Some(1_700_000_100),
21286        };
21287        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
21288
21289        let error = subscriber
21290            .replace_interest_owners_with_global_backfill(
21291                vec![
21292                    (
21293                        HandlerId::new("pool-a"),
21294                        vec![interest(Address::repeat_byte(0xa1))],
21295                    ),
21296                    (
21297                        HandlerId::new("pool-b"),
21298                        vec![ReactiveInterest::Logs(LogInterest {
21299                            // A distinct block option prevents provider-filter
21300                            // fan-in, exercising the two-unit capacity edge.
21301                            provider_filter: Filter::new()
21302                                .address(Address::repeat_byte(0xb2))
21303                                .from_block(7),
21304                            local_matcher: None,
21305                            route_key: None,
21306                        })],
21307                    ),
21308                ],
21309                backfill,
21310            )
21311            .expect_err("two backfills exceed atomic capacity");
21312        assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
21313        assert!(
21314            subscriber
21315                .owner_interests(&HandlerId::new("crash-stale"))
21316                .is_some(),
21317            "failed replacement must preserve the prior topology"
21318        );
21319        assert!(
21320            subscriber
21321                .owner_interests(&HandlerId::new("pool-a"))
21322                .is_none()
21323        );
21324        assert_eq!(subscriber.base_interests.len(), 1);
21325        assert_eq!(subscriber.pending_records.len(), 1);
21326
21327        subscriber
21328            .replace_interest_owners_with_global_backfill(
21329                vec![(
21330                    HandlerId::new("pool-a"),
21331                    vec![interest(Address::repeat_byte(0xa1))],
21332                )],
21333                backfill,
21334            )
21335            .expect("replacement within capacity");
21336        assert!(
21337            subscriber
21338                .owner_interests(&HandlerId::new("crash-stale"))
21339                .is_none(),
21340            "successful exact replacement removes stale owners"
21341        );
21342        assert!(
21343            subscriber.base_interests.is_empty(),
21344            "successful exact replacement removes stale unowned interests"
21345        );
21346        assert!(
21347            subscriber.drain_next_scoped_batch().is_none(),
21348            "stale canonical delivery must not escape before C + 1 recovery"
21349        );
21350        assert!(
21351            subscriber
21352                .owner_interests(&HandlerId::new("pool-a"))
21353                .is_some()
21354        );
21355        assert_eq!(subscriber.pending_backfills.len(), 1);
21356        assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
21357        assert!(
21358            subscriber.pending_backfills[0].owner.is_none(),
21359            "startup history must be global canonical catch-up, not owner-only"
21360        );
21361    }
21362
21363    #[test]
21364    fn exclusive_canonical_backfill_rejects_block_number_overflow() {
21365        let baseline = BlockRef {
21366            number: u64::MAX,
21367            hash: B256::repeat_byte(0xff),
21368            parent_hash: None,
21369            timestamp: None,
21370        };
21371        assert!(matches!(
21372            SubscriberBackfill::after_canonical_block(baseline),
21373            Err(SubscriberError::InvalidConfig(_))
21374        ));
21375    }
21376
21377    #[tokio::test(flavor = "multi_thread")]
21378    async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
21379        let asserter = Asserter::new();
21380        asserter.push_success(&101u64);
21381        asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
21382        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
21383        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21384        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21385            provider,
21386            SubscriberMode::Auto,
21387            SubscriberConfig::default(),
21388        );
21389        let baseline = BlockRef {
21390            number: 100,
21391            hash: B256::repeat_byte(0xaa),
21392            parent_hash: None,
21393            timestamp: None,
21394        };
21395        let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
21396        subscriber
21397            .add_interest_owner_with_backfill(
21398                HandlerId::new("pool"),
21399                &[ReactiveInterest::Logs(LogInterest {
21400                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
21401                    local_matcher: None,
21402                    route_key: None,
21403                })],
21404                backfill,
21405            )
21406            .expect("queue post-baseline backfill");
21407
21408        let error = subscriber
21409            .drain_pending_backfills()
21410            .await
21411            .expect_err("provider branch differs at retained baseline");
21412        assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
21413        assert_eq!(subscriber.pending_backfills.len(), 1);
21414        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
21415        assert!(subscriber.pending_records.is_empty());
21416    }
21417
21418    #[tokio::test(flavor = "multi_thread")]
21419    #[cfg(feature = "reactive-ws")]
21420    async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
21421    {
21422        let asserter = Asserter::new();
21423        let retained = BlockRef {
21424            number: 10,
21425            hash: B256::repeat_byte(10),
21426            parent_hash: Some(B256::repeat_byte(9)),
21427            timestamp: Some(1_700_000_010),
21428        };
21429        let activation = BlockRef {
21430            number: 12,
21431            hash: B256::repeat_byte(12),
21432            parent_hash: Some(B256::repeat_byte(11)),
21433            timestamp: Some(1_700_000_012),
21434        };
21435
21436        // 257 distinct logical block options cross the 256-filter request
21437        // chunk boundary. Each window therefore makes two concurrent log
21438        // requests whose responses deliberately arrive in reverse order.
21439        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21440        asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
21441        asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
21442        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21443        asserter.push_success(&activation.number);
21444        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
21445        asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
21446        asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
21447        asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
21448        asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
21449        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
21450        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21451            provider,
21452            SubscriberMode::Auto,
21453            SubscriberConfig::default(),
21454        );
21455        let interests = (0..257)
21456            .map(|start| {
21457                ReactiveInterest::Logs(LogInterest {
21458                    provider_filter: Filter::new()
21459                        .address(Address::repeat_byte(0x42))
21460                        .event_signature(B256::repeat_byte(0x01))
21461                        .from_block(start),
21462                    local_matcher: None,
21463                    route_key: None,
21464                })
21465            })
21466            .collect::<Vec<_>>();
21467        subscriber
21468            .add_interest_owner_with_canonical_catchup(
21469                HandlerId::new("many-filters"),
21470                &interests,
21471                retained,
21472            )
21473            .expect("queue coordinated windows");
21474        assert_eq!(subscriber.pending_backfills.len(), 2);
21475        assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
21476        assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
21477
21478        subscriber
21479            .drain_pending_backfills()
21480            .await
21481            .expect("owner filter group");
21482        let owner = subscriber
21483            .drain_next_scoped_batch()
21484            .expect("owner ordered batch");
21485        assert_eq!(owner.records.len(), 2);
21486        assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
21487        assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
21488        assert!(
21489            owner.records.iter().all(|record| matches!(
21490                record.scope,
21491                SubscriberInputScope::OwnerOnlyHandlers { .. }
21492            ))
21493        );
21494
21495        subscriber
21496            .drain_pending_backfills()
21497            .await
21498            .expect("global filter group");
21499        let global = subscriber
21500            .drain_next_scoped_batch()
21501            .expect("global ordered batch");
21502        assert_eq!(global.records.len(), 2);
21503        assert_eq!(
21504            global.records[0].record.context.block.map(|b| b.number),
21505            Some(11)
21506        );
21507        assert_eq!(
21508            global.records[1].record.context.block.map(|b| b.number),
21509            Some(12)
21510        );
21511        assert!(
21512            global
21513                .records
21514                .iter()
21515                .all(|record| record.scope.is_canonical())
21516        );
21517        assert!(matches!(
21518            global.chain_controls.as_slice(),
21519            [ChainControl::Barrier {
21520                block: Some(block),
21521                ..
21522            }] if block == &activation
21523        ));
21524    }
21525
21526    #[tokio::test(flavor = "multi_thread")]
21527    #[cfg(feature = "reactive-ws")]
21528    async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
21529        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21530        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21531            provider,
21532            SubscriberMode::PubSub,
21533            SubscriberConfig::default(),
21534        );
21535        subscriber.chain_id = Some(1);
21536        let interest = ReactiveInterest::Logs(LogInterest {
21537            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21538            local_matcher: None,
21539            route_key: None,
21540        });
21541        let owner_a = subscriber
21542            .stage_interest_owner(
21543                HandlerId::new("owner-a"),
21544                std::slice::from_ref(&interest),
21545                SubscriberOwnerStart::Live,
21546            )
21547            .unwrap();
21548        let owner_b = subscriber
21549            .stage_interest_owner(
21550                HandlerId::new("owner-b"),
21551                &[interest],
21552                SubscriberOwnerStart::Live,
21553            )
21554            .unwrap();
21555
21556        subscriber.enqueue_event(SubscriberEvent::Log {
21557            source_id: 0,
21558            log: rpc_log(false),
21559        });
21560        assert!(subscriber.abort_interest_owner(&owner_a));
21561
21562        let batch = subscriber
21563            .next_scoped_batch()
21564            .await
21565            .unwrap()
21566            .expect("shared canonical delivery remains queued");
21567        assert_eq!(batch.records.len(), 1);
21568        assert_eq!(
21569            batch.records[0].scope,
21570            SubscriberInputScope::Canonical {
21571                owners: vec![owner_b]
21572            }
21573        );
21574    }
21575
21576    #[tokio::test(flavor = "multi_thread")]
21577    #[cfg(feature = "reactive-ws")]
21578    async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
21579        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21580        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21581            provider,
21582            SubscriberMode::PubSub,
21583            SubscriberConfig::default(),
21584        );
21585        subscriber.chain_id = Some(1);
21586        let epoch = subscriber
21587            .stage_interest_owner(
21588                HandlerId::new("owner"),
21589                &[ReactiveInterest::Logs(LogInterest {
21590                    provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21591                    local_matcher: None,
21592                    route_key: None,
21593                })],
21594                SubscriberOwnerStart::Live,
21595            )
21596            .unwrap();
21597        let log = rpc_log(false);
21598
21599        subscriber.enqueue_owner_record(
21600            log_input_record(log.clone(), InputSource::Backfill),
21601            epoch.clone(),
21602        );
21603        subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
21604
21605        let batch = subscriber
21606            .next_scoped_batch()
21607            .await
21608            .unwrap()
21609            .expect("owner backfill and canonical live delivery");
21610        assert_eq!(batch.records.len(), 2);
21611        assert_eq!(
21612            batch.records[0].scope,
21613            SubscriberInputScope::OwnerOnly {
21614                owners: vec![epoch]
21615            }
21616        );
21617        assert_eq!(
21618            batch.records[1].scope,
21619            SubscriberInputScope::Canonical { owners: Vec::new() },
21620            "owner replay dedupe must not suppress the global live record"
21621        );
21622    }
21623
21624    #[tokio::test(flavor = "multi_thread")]
21625    #[cfg(feature = "reactive-polling")]
21626    async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
21627        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21628        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21629            provider,
21630            SubscriberMode::Polling,
21631            SubscriberConfig {
21632                max_batch_size: 2,
21633                ..SubscriberConfig::default()
21634            },
21635        );
21636        let interest = ReactiveInterest::Logs(LogInterest {
21637            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21638            local_matcher: None,
21639            route_key: None,
21640        });
21641        let epoch = subscriber
21642            .stage_interest_owner(
21643                HandlerId::new("owner"),
21644                std::slice::from_ref(&interest),
21645                SubscriberOwnerStart::Live,
21646            )
21647            .unwrap();
21648        subscriber.sources_dirty = false;
21649
21650        let mut duplicate = rpc_log(false);
21651        duplicate.transaction_hash = Some(B256::repeat_byte(1));
21652        duplicate.log_index = Some(0);
21653        let events = (0u8..10).map(|index| {
21654            let mut log = rpc_log(false);
21655            log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
21656            log.log_index = Some(index as u64);
21657            SubscriberEvent::Log { source_id: 0, log }
21658        });
21659        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
21660        let mut streams = SubscriberStreams::new();
21661        streams.push(
21662            SubscriberStreamSource::PollingLog { filter },
21663            stream::iter(events).boxed(),
21664        );
21665        subscriber.state = AlloySubscriberState::Active(streams);
21666
21667        let mut polls = 0usize;
21668        let fetched_duplicate = duplicate.clone();
21669        let fetch = poll_fn(move |cx| {
21670            polls += 1;
21671            if polls > 10 {
21672                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
21673            } else {
21674                cx.waker().wake_by_ref();
21675                std::task::Poll::Pending
21676            }
21677        });
21678        let target_epochs = HashSet::from([epoch.clone()]);
21679        let fetched_duplicate = subscriber
21680            .drive_reconcile_fetch(fetch, &target_epochs)
21681            .await
21682            .unwrap();
21683        subscriber.enqueue_owner_record_for_owners_unmerged(
21684            log_input_record(fetched_duplicate, InputSource::Backfill),
21685            vec![epoch.clone()],
21686        );
21687        subscriber.promote_reconcile_owner_records(&target_epochs);
21688
21689        assert_eq!(subscriber.pending_records.len(), 20);
21690        assert!(subscriber.pending_records.iter().take(10).all(|record| {
21691            record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
21692        }));
21693        assert!(subscriber.pending_records.iter().skip(10).all(|record| {
21694            record.scope
21695                == SubscriberInputScope::OwnerOnly {
21696                    owners: vec![epoch.clone()],
21697                }
21698        }));
21699    }
21700
21701    #[tokio::test(flavor = "multi_thread")]
21702    #[cfg(feature = "reactive-polling")]
21703    async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
21704        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21705        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21706            provider,
21707            SubscriberMode::Polling,
21708            SubscriberConfig::default(),
21709        );
21710        subscriber.chain_id = Some(1);
21711        subscriber.sources_dirty = false;
21712        let mut first_poll = true;
21713        let fetch = poll_fn(move |cx| {
21714            if first_poll {
21715                first_poll = false;
21716                cx.waker().wake_by_ref();
21717                std::task::Poll::Pending
21718            } else {
21719                std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
21720            }
21721        });
21722
21723        let result = subscriber
21724            .drive_reconcile_fetch(fetch, &HashSet::new())
21725            .await
21726            .expect("an empty live topology must not be mistaken for termination");
21727        assert_eq!(result, "certified");
21728    }
21729
21730    #[tokio::test(flavor = "multi_thread")]
21731    #[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
21732    async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
21733        use alloy_rpc_types_eth::{Block, Header};
21734
21735        let asserter = Asserter::new();
21736        let baseline = BlockRef {
21737            number: 100,
21738            hash: B256::repeat_byte(0x64),
21739            parent_hash: Some(B256::repeat_byte(0x63)),
21740            timestamp: Some(1_700_000_100),
21741        };
21742        let through = BlockRef {
21743            number: 101,
21744            hash: B256::repeat_byte(0x65),
21745            parent_hash: Some(baseline.hash),
21746            timestamp: Some(1_700_000_101),
21747        };
21748        let rpc_block = || -> Block {
21749            Block::empty(Header {
21750                hash: through.hash,
21751                inner: alloy_consensus::Header {
21752                    number: through.number,
21753                    parent_hash: through.parent_hash.unwrap(),
21754                    timestamp: through.timestamp.unwrap(),
21755                    ..Default::default()
21756                },
21757                total_difficulty: None,
21758                size: None,
21759            })
21760        };
21761        asserter.push_success(&Some(rpc_block()));
21762        asserter.push_success(&Vec::<Log>::new());
21763        asserter.push_success(&Some(rpc_block()));
21764        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
21765        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21766            provider,
21767            SubscriberMode::PubSub,
21768            SubscriberConfig::default(),
21769        );
21770        subscriber.chain_id = Some(1);
21771        let interest = ReactiveInterest::Logs(LogInterest {
21772            provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
21773            local_matcher: None,
21774            route_key: None,
21775        });
21776        let epoch = subscriber
21777            .stage_interest_owner(
21778                HandlerId::new("reconnect-anchor"),
21779                std::slice::from_ref(&interest),
21780                SubscriberOwnerStart::PostBlock(baseline),
21781            )
21782            .unwrap();
21783        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
21784        let source = SubscriberStreamSource::PubSubLog {
21785            id: subscriber.log_source_id(&filter),
21786            filter: filter.clone(),
21787        };
21788        let mut streams = SubscriberStreams::new();
21789        streams.push(source, stream::pending().boxed());
21790        subscriber.state = AlloySubscriberState::Active(streams);
21791        subscriber.sources_dirty = false;
21792
21793        subscriber
21794            .reconcile_interest_owner(&epoch, through)
21795            .await
21796            .unwrap();
21797        assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
21798        assert!(asserter.read_q().is_empty());
21799    }
21800
21801    #[tokio::test(flavor = "multi_thread")]
21802    #[cfg(feature = "reactive-polling")]
21803    async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
21804        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21805        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21806            provider,
21807            SubscriberMode::Polling,
21808            SubscriberConfig::default(),
21809        );
21810        let interest = ReactiveInterest::Logs(LogInterest {
21811            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21812            local_matcher: None,
21813            route_key: None,
21814        });
21815        let epoch = subscriber
21816            .stage_interest_owner(
21817                HandlerId::new("owner"),
21818                std::slice::from_ref(&interest),
21819                SubscriberOwnerStart::PostBlock(BlockRef {
21820                    number: 100,
21821                    hash: B256::repeat_byte(0x64),
21822                    parent_hash: None,
21823                    timestamp: None,
21824                }),
21825            )
21826            .unwrap();
21827        subscriber.sources_dirty = false;
21828
21829        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
21830        let event = SubscriberEvent::Log {
21831            source_id: 0,
21832            log: rpc_log(false),
21833        };
21834        let mut streams = SubscriberStreams::new();
21835        streams.push(
21836            SubscriberStreamSource::PollingLog { filter },
21837            stream::once(async move { event })
21838                .chain(stream::pending())
21839                .boxed(),
21840        );
21841        subscriber.state = AlloySubscriberState::Active(streams);
21842
21843        let targets = HashSet::from([epoch.clone()]);
21844        {
21845            let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
21846            let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
21847            futures::pin_mut!(drive);
21848            poll_fn(|cx| {
21849                assert!(drive.as_mut().poll(cx).is_pending());
21850                std::task::Poll::Ready(())
21851            })
21852            .await;
21853        }
21854
21855        assert_eq!(subscriber.pending_records.len(), 1);
21856        assert_eq!(
21857            subscriber.pending_records[0].scope,
21858            SubscriberInputScope::Canonical { owners: Vec::new() },
21859            "canonical delivery commits immediately at a cancellation-safe boundary"
21860        );
21861        assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
21862
21863        subscriber
21864            .drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
21865            .await
21866            .unwrap();
21867        subscriber.promote_reconcile_owner_records(&targets);
21868        assert!(subscriber.pending_reconcile_owner_records.is_empty());
21869        assert_eq!(subscriber.pending_records.len(), 2);
21870        assert_eq!(
21871            subscriber.pending_records[0].scope,
21872            SubscriberInputScope::Canonical { owners: Vec::new() },
21873            "canonical delivery remains target-excluded"
21874        );
21875        assert_eq!(
21876            subscriber.pending_records[1].scope,
21877            SubscriberInputScope::OwnerOnly {
21878                owners: vec![epoch]
21879            },
21880            "retry commit appends hidden owner delivery after historical catch-up"
21881        );
21882    }
21883
21884    #[tokio::test(flavor = "multi_thread")]
21885    #[cfg(feature = "reactive-ws")]
21886    async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
21887        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21888        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21889            provider,
21890            SubscriberMode::PubSub,
21891            SubscriberConfig {
21892                reconnect: SubscriberReconnectConfig {
21893                    initial_delay: Duration::from_secs(60),
21894                    ..SubscriberReconnectConfig::default()
21895                },
21896                ..SubscriberConfig::default()
21897            },
21898        );
21899        subscriber.chain_id = Some(1);
21900        let interest = ReactiveInterest::Logs(LogInterest {
21901            provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
21902            local_matcher: None,
21903            route_key: None,
21904        });
21905        let epoch = subscriber
21906            .stage_interest_owner(
21907                HandlerId::new("owner"),
21908                std::slice::from_ref(&interest),
21909                SubscriberOwnerStart::PostBlock(BlockRef {
21910                    number: 7,
21911                    hash: B256::repeat_byte(0x07),
21912                    parent_hash: Some(B256::repeat_byte(0x06)),
21913                    timestamp: Some(1_700_000_007),
21914                }),
21915            )
21916            .unwrap();
21917        subscriber.sources_dirty = false;
21918        subscriber.stream_revision = 1;
21919        let entry = subscriber
21920            .owned_interests
21921            .iter_mut()
21922            .find(|entry| entry.epoch.as_ref() == Some(&epoch))
21923            .unwrap();
21924        entry.progress = Some(SubscriberOwnerProgress {
21925            owner: epoch.clone(),
21926            through: entry.baseline.unwrap(),
21927        });
21928        entry.progress_stream_revision = Some(1);
21929
21930        let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
21931        let source = SubscriberStreamSource::PubSubLog {
21932            id: subscriber.log_source_id(&filter),
21933            filter,
21934        };
21935        let mut streams = SubscriberStreams::new();
21936        streams.push(
21937            source.clone(),
21938            stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
21939        );
21940        subscriber.state = AlloySubscriberState::Active(streams);
21941        let prior_revision = subscriber.stream_revision;
21942
21943        let mut first_poll = true;
21944        let control = poll_fn(move |cx| {
21945            if first_poll {
21946                first_poll = false;
21947                cx.waker().wake_by_ref();
21948                std::task::Poll::Pending
21949            } else {
21950                std::task::Poll::Ready("stop")
21951            }
21952        });
21953        futures::pin_mut!(control);
21954        let outcome = subscriber
21955            .next_scoped_batch_or(control.as_mut())
21956            .await
21957            .unwrap();
21958
21959        assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
21960        assert!(subscriber.sources_dirty);
21961        assert!(subscriber.stream_revision > prior_revision);
21962        assert!(
21963            !subscriber.activate_interest_owner(&epoch),
21964            "progress certified against the terminated stream revision is stale"
21965        );
21966    }
21967
21968    #[tokio::test]
21969    #[cfg(feature = "reactive-ws")]
21970    async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
21971        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
21972        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
21973            provider,
21974            SubscriberMode::PubSub,
21975            SubscriberConfig::default(),
21976        );
21977        subscriber.chain_id = Some(1);
21978        subscriber
21979            .register_interests(&[
21980                ReactiveInterest::Logs(LogInterest {
21981                    provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
21982                    local_matcher: None,
21983                    route_key: None,
21984                }),
21985                ReactiveInterest::Logs(LogInterest {
21986                    provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
21987                    local_matcher: None,
21988                    route_key: None,
21989                }),
21990                ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
21991            ])
21992            .await
21993            .expect("register base interests");
21994
21995        // The two default-block-option log filters merge into one address
21996        // superset (existing consolidation behavior), so there is one log source
21997        // — assigned id 0, before the pending-hash source.
21998        let sources = subscriber.stream_sources().expect("stream sources");
21999        assert_eq!(sources.len(), 2);
22000        assert!(matches!(
22001            &sources[0],
22002            SubscriberStreamSource::PubSubLog { id: 0, .. }
22003        ));
22004        assert!(matches!(
22005            sources[1],
22006            SubscriberStreamSource::PubSubPendingHashes
22007        ));
22008
22009        // Ids are stable across repeated source construction.
22010        let again = subscriber.stream_sources().expect("stream sources again");
22011        assert!(again[0].same_key(&sources[0]));
22012    }
22013
22014    #[tokio::test(flavor = "multi_thread")]
22015    #[cfg(feature = "reactive-ws")]
22016    async fn pubsub_stream_termination_attempts_reconnect_before_error() {
22017        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22018        let mut subscriber = AlloySubscriber::new(
22019            provider,
22020            SubscriberMode::PubSub,
22021            SubscriberConfig {
22022                reconnect: SubscriberReconnectConfig {
22023                    initial_delay: Duration::ZERO,
22024                    retry_delay: Duration::ZERO,
22025                    max_delay: Duration::ZERO,
22026                    max_attempts: Some(1),
22027                    ..SubscriberReconnectConfig::default()
22028                },
22029                ..SubscriberConfig::default()
22030            },
22031        );
22032        subscriber.chain_id = Some(1);
22033        subscriber.interests = vec![ReactiveInterest::PendingTransactions(
22034            PendingTxInterest::default(),
22035        )];
22036
22037        let mut streams = SubscriberStreams::new();
22038        let source = SubscriberStreamSource::PubSubPendingHashes;
22039        streams.push(
22040            source,
22041            stream::once(async {
22042                SubscriberEvent::<Ethereum>::StreamTerminated(
22043                    SubscriberStreamSource::PubSubPendingHashes,
22044                )
22045            })
22046            .boxed(),
22047        );
22048        subscriber.state = AlloySubscriberState::Active(streams);
22049
22050        let result = subscriber.next_batch().await;
22051        assert!(
22052            matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
22053            "terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
22054        );
22055    }
22056
22057    #[tokio::test]
22058    #[cfg(feature = "reactive-ws")]
22059    async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
22060        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22061        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22062            provider,
22063            SubscriberMode::PubSub,
22064            SubscriberConfig {
22065                preconfirmations: PreconfirmationMode::Required,
22066                ..SubscriberConfig::default()
22067            },
22068        )
22069        .with_provider_ref(ProviderRef::new("base-paid", 7));
22070        subscriber.chain_id = Some(8_453);
22071        subscriber.base_interests = vec![log_interest_matching_rpc_log()];
22072        subscriber.interests = subscriber.base_interests.clone();
22073        subscriber.sources_dirty = false;
22074
22075        let preview: BaseFlashblockWirePayload = serde_json::from_str(
22076            r#"{
22077                "hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
22078                "number":"0x65",
22079                "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
22080                "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
22081                "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
22082                "timestamp":"0x6553f165",
22083                "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
22084            }"#,
22085        )
22086        .unwrap();
22087        let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
22088        subscriber.latest_preconfirmation = Some(preview);
22089
22090        let mut streams = SubscriberStreams::new();
22091        streams.push(
22092            SubscriberStreamSource::BaseFlashblocks,
22093            stream::once(async {
22094                SubscriberEvent::<Ethereum>::StreamTerminated(
22095                    SubscriberStreamSource::BaseFlashblocks,
22096                )
22097            })
22098            .boxed(),
22099        );
22100        subscriber.state = AlloySubscriberState::Active(streams);
22101
22102        let batch = subscriber
22103            .next_scoped_batch()
22104            .await
22105            .expect("termination handling succeeds")
22106            .expect("invalidation is delivered");
22107        assert!(batch.preconfirmation_invalidated());
22108        assert!(subscriber.latest_preconfirmation.is_none());
22109        assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
22110        assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
22111        assert!(
22112            subscriber
22113                .pending_flashblock_reconnect_sources
22114                .iter()
22115                .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
22116        );
22117        assert!(
22118            subscriber
22119                .pending_flashblock_reconnect_sources
22120                .iter()
22121                .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
22122        );
22123        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22124            panic!("subscriber remains active while reconnect is pending")
22125        };
22126        assert!(
22127            streams
22128                .entries
22129                .iter()
22130                .all(|entry| !entry.source.is_flashblocks())
22131        );
22132    }
22133
22134    #[tokio::test]
22135    #[cfg(feature = "reactive-ws")]
22136    async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
22137        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22138        let filter = Filter::new().address(Address::repeat_byte(0x42));
22139        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22140            provider,
22141            SubscriberMode::PubSub,
22142            SubscriberConfig {
22143                preconfirmations: PreconfirmationMode::Preferred,
22144                reconnect: SubscriberReconnectConfig {
22145                    enabled: false,
22146                    ..SubscriberReconnectConfig::default()
22147                },
22148                ..SubscriberConfig::default()
22149            },
22150        )
22151        .with_provider_ref(ProviderRef::new("base-paid", 1));
22152        subscriber.chain_id = Some(8_453);
22153        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22154            provider_filter: filter.clone(),
22155            local_matcher: None,
22156            route_key: None,
22157        })];
22158        subscriber.interests = subscriber.base_interests.clone();
22159        subscriber.log_source_ids.insert(filter.clone(), 0);
22160        subscriber.next_log_source_id = 1;
22161
22162        let canonical_source = SubscriberStreamSource::PubSubLog {
22163            id: 0,
22164            filter: filter.clone(),
22165        };
22166        let mut streams = SubscriberStreams::new();
22167        streams.push(
22168            canonical_source.clone(),
22169            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22170        );
22171        subscriber.state = AlloySubscriberState::Active(streams);
22172        subscriber.sources_dirty = true;
22173
22174        subscriber
22175            .ensure_streams()
22176            .await
22177            .expect("preferred Flashblocks setup degrades to canonical-only");
22178        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22179            panic!("canonical stream remains active")
22180        };
22181        assert!(streams.contains_source(&canonical_source));
22182        assert!(
22183            streams
22184                .entries
22185                .iter()
22186                .all(|entry| !entry.source.is_flashblocks())
22187        );
22188        assert!(subscriber.pending_flashblock_reconnects.is_empty());
22189        assert!(!subscriber.sources_dirty);
22190    }
22191
22192    #[tokio::test]
22193    #[cfg(feature = "reactive-ws")]
22194    async fn required_initial_flashblock_rejection_remains_fail_closed() {
22195        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22196        let filter = Filter::new().address(Address::repeat_byte(0x42));
22197        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22198            provider,
22199            SubscriberMode::PubSub,
22200            SubscriberConfig {
22201                preconfirmations: PreconfirmationMode::Required,
22202                reconnect: SubscriberReconnectConfig {
22203                    enabled: false,
22204                    ..SubscriberReconnectConfig::default()
22205                },
22206                ..SubscriberConfig::default()
22207            },
22208        )
22209        .with_provider_ref(ProviderRef::new("base-paid", 1));
22210        subscriber.chain_id = Some(8_453);
22211        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22212            provider_filter: filter.clone(),
22213            local_matcher: None,
22214            route_key: None,
22215        })];
22216        subscriber.interests = subscriber.base_interests.clone();
22217        subscriber.log_source_ids.insert(filter.clone(), 0);
22218        subscriber.next_log_source_id = 1;
22219
22220        let canonical_source = SubscriberStreamSource::PubSubLog {
22221            id: 0,
22222            filter: filter.clone(),
22223        };
22224        let mut streams = SubscriberStreams::new();
22225        streams.push(
22226            canonical_source.clone(),
22227            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22228        );
22229        subscriber.state = AlloySubscriberState::Active(streams);
22230        subscriber.sources_dirty = true;
22231
22232        let error = subscriber
22233            .ensure_streams()
22234            .await
22235            .expect_err("required Flashblocks setup must fail closed");
22236        assert!(matches!(error, SubscriberError::Provider(_)));
22237        let AlloySubscriberState::Active(streams) = &subscriber.state else {
22238            panic!("the already-connected canonical stream is retained")
22239        };
22240        assert!(streams.contains_source(&canonical_source));
22241    }
22242
22243    #[tokio::test]
22244    #[cfg(feature = "reactive-ws")]
22245    async fn preferred_flashblock_termination_preserves_canonical_delivery() {
22246        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22247        let filter = Filter::new().address(Address::repeat_byte(0x42));
22248        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22249            provider,
22250            SubscriberMode::PubSub,
22251            SubscriberConfig {
22252                preconfirmations: PreconfirmationMode::Preferred,
22253                reconnect: SubscriberReconnectConfig {
22254                    enabled: false,
22255                    ..SubscriberReconnectConfig::default()
22256                },
22257                ..SubscriberConfig::default()
22258            },
22259        )
22260        .with_provider_ref(ProviderRef::new("base-paid", 1));
22261        subscriber.chain_id = Some(8_453);
22262        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22263            provider_filter: filter.clone(),
22264            local_matcher: None,
22265            route_key: None,
22266        })];
22267        subscriber.interests = subscriber.base_interests.clone();
22268        subscriber.log_source_ids.insert(filter.clone(), 0);
22269        subscriber.next_log_source_id = 1;
22270        subscriber.sources_dirty = false;
22271
22272        let mut streams = SubscriberStreams::new();
22273        streams.push(
22274            SubscriberStreamSource::BaseFlashblocks,
22275            stream::once(async {
22276                SubscriberEvent::<Ethereum>::StreamTerminated(
22277                    SubscriberStreamSource::BaseFlashblocks,
22278                )
22279            })
22280            .boxed(),
22281        );
22282        streams.push(
22283            SubscriberStreamSource::PubSubLog {
22284                id: 0,
22285                filter: filter.clone(),
22286            },
22287            stream::once(async {
22288                SubscriberEvent::<Ethereum>::Log {
22289                    source_id: 0,
22290                    log: rpc_log(false),
22291                }
22292            })
22293            .boxed(),
22294        );
22295        subscriber.state = AlloySubscriberState::Active(streams);
22296
22297        let invalidation = subscriber
22298            .next_scoped_batch()
22299            .await
22300            .expect("preferred termination does not fail")
22301            .expect("invalidation is delivered");
22302        assert!(invalidation.preconfirmation_invalidated());
22303
22304        let canonical = subscriber
22305            .next_scoped_batch()
22306            .await
22307            .expect("canonical stream remains healthy")
22308            .expect("canonical log is delivered");
22309        assert!(!canonical.preconfirmation_invalidated());
22310        assert_eq!(canonical.records().len(), 1);
22311        assert_eq!(
22312            canonical.records()[0].record.context.source,
22313            InputSource::Subscription
22314        );
22315    }
22316
22317    #[tokio::test]
22318    #[cfg(feature = "reactive-ws")]
22319    async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
22320        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22321        let filter = Filter::new().address(Address::repeat_byte(0x42));
22322        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22323            provider,
22324            SubscriberMode::PubSub,
22325            SubscriberConfig {
22326                preconfirmations: PreconfirmationMode::Preferred,
22327                reconnect: SubscriberReconnectConfig {
22328                    enabled: false,
22329                    ..SubscriberReconnectConfig::default()
22330                },
22331                ..SubscriberConfig::default()
22332            },
22333        )
22334        .with_provider_ref(ProviderRef::new("base-paid", 1));
22335        subscriber.chain_id = Some(8_453);
22336        subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
22337            provider_filter: filter.clone(),
22338            local_matcher: None,
22339            route_key: None,
22340        })];
22341        subscriber.interests = subscriber.base_interests.clone();
22342        subscriber.log_source_ids.insert(filter.clone(), 0);
22343        subscriber.next_log_source_id = 1;
22344        subscriber.sources_dirty = false;
22345
22346        let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
22347        let mut streams = SubscriberStreams::new();
22348        streams.push(
22349            canonical_source,
22350            stream::once(async {
22351                tokio::time::sleep(Duration::from_millis(1)).await;
22352                SubscriberEvent::<Ethereum>::Log {
22353                    source_id: 0,
22354                    log: rpc_log(false),
22355                }
22356            })
22357            .boxed(),
22358        );
22359        subscriber.state = AlloySubscriberState::Active(streams);
22360
22361        let source = SubscriberStreamSource::BaseFlashblocks;
22362        subscriber
22363            .pending_flashblock_reconnect_sources
22364            .push(source.clone());
22365        subscriber
22366            .pending_flashblock_reconnects
22367            .push(Box::pin(async move {
22368                (
22369                    source,
22370                    Err(SubscriberError::Provider(
22371                        "test reconnect window exhausted".to_owned(),
22372                    )),
22373                )
22374            }));
22375
22376        let canonical = subscriber
22377            .next_scoped_batch()
22378            .await
22379            .expect("preferred reconnect exhaustion does not fail")
22380            .expect("canonical log is delivered");
22381        assert_eq!(canonical.records().len(), 1);
22382        assert!(subscriber.pending_flashblock_reconnects.is_empty());
22383    }
22384
22385    #[test]
22386    fn backfilled_logs_skip_recent_subscription_duplicates() {
22387        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22388        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22389            provider,
22390            SubscriberMode::PubSub,
22391            SubscriberConfig::default(),
22392        );
22393        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22394            provider_filter: Filter::new()
22395                .address(Address::repeat_byte(0x42))
22396                .event_signature(B256::repeat_byte(0x01)),
22397            local_matcher: None,
22398            route_key: None,
22399        })];
22400
22401        let log = rpc_log(false);
22402        subscriber.enqueue_event(SubscriberEvent::Log {
22403            source_id: 0,
22404            log: log.clone(),
22405        });
22406        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
22407            source_id: 0,
22408            logs: vec![log],
22409        });
22410
22411        assert_eq!(subscriber.pending_records.len(), 1);
22412        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22413        assert_eq!(
22414            subscriber.pending_records[0].context.source,
22415            InputSource::Subscription
22416        );
22417    }
22418
22419    #[test]
22420    fn backfilled_logs_surface_with_backfill_source() {
22421        // A backfilled log with no prior subscription duplicate is delivered as
22422        // an `InputSource::Backfill` record (the positive side of the dedup test,
22423        // pinning the README's "marking recovered records as InputSource::Backfill"
22424        // claim — the only place that source is produced).
22425        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22426        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22427            provider,
22428            SubscriberMode::PubSub,
22429            SubscriberConfig::default(),
22430        );
22431        subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
22432            provider_filter: Filter::new()
22433                .address(Address::repeat_byte(0x42))
22434                .event_signature(B256::repeat_byte(0x01)),
22435            local_matcher: None,
22436            route_key: None,
22437        })];
22438
22439        subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
22440            source_id: 0,
22441            logs: vec![rpc_log(false)],
22442        });
22443
22444        assert_eq!(subscriber.pending_records.len(), 1);
22445        assert_eq!(
22446            subscriber.pending_records[0].context.source,
22447            InputSource::Backfill
22448        );
22449        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22450    }
22451
22452    #[test]
22453    #[cfg(feature = "reactive-ws")]
22454    fn owner_removal_preserves_delivery_and_dedupe_state() {
22455        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22456        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22457            provider,
22458            SubscriberMode::PubSub,
22459            SubscriberConfig::default(),
22460        );
22461        subscriber
22462            .add_interest_owner(
22463                HandlerId::new("pool-a"),
22464                &[ReactiveInterest::Logs(LogInterest {
22465                    provider_filter: Filter::new()
22466                        .address(Address::repeat_byte(0x42))
22467                        .event_signature(B256::repeat_byte(0x01)),
22468                    local_matcher: None,
22469                    route_key: None,
22470                })],
22471            )
22472            .expect("register pool-a owner");
22473        subscriber
22474            .add_interest_owner(
22475                HandlerId::new("pool-b"),
22476                &[ReactiveInterest::Logs(LogInterest {
22477                    provider_filter: Filter::new()
22478                        .address(Address::repeat_byte(0x24))
22479                        .event_signature(B256::repeat_byte(0x02)),
22480                    local_matcher: None,
22481                    route_key: None,
22482                })],
22483            )
22484            .expect("register pool-b owner");
22485
22486        // Allocate source ids the way live stream setup would (pool-a -> id 0),
22487        // so the injected delivery anchor hangs off a referenced filter.
22488        let sources = subscriber.stream_sources().expect("stream sources");
22489        subscriber.enqueue_event(SubscriberEvent::Log {
22490            source_id: 0,
22491            log: rpc_log(false),
22492        });
22493        let mut streams = SubscriberStreams::new();
22494        streams.push(
22495            sources[0].clone(),
22496            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22497        );
22498        subscriber.state = AlloySubscriberState::Active(streams);
22499        assert_eq!(subscriber.pending_records.len(), 1);
22500        assert_eq!(subscriber.recent_input_refs.len(), 1);
22501        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22502
22503        let removed = subscriber
22504            .remove_interest_owner(&HandlerId::new("pool-b"))
22505            .expect("pool-b should be removed");
22506
22507        assert_eq!(removed.len(), 1);
22508        assert_eq!(subscriber.pending_records.len(), 1);
22509        assert_eq!(subscriber.recent_input_refs.len(), 1);
22510        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22511        assert!(
22512            subscriber
22513                .owner_interests(&HandlerId::new("pool-a"))
22514                .is_some()
22515        );
22516        assert!(
22517            subscriber
22518                .owner_interests(&HandlerId::new("pool-b"))
22519                .is_none()
22520        );
22521        assert_eq!(subscriber.registered_interests().len(), 1);
22522    }
22523
22524    #[test]
22525    #[cfg(feature = "reactive-ws")]
22526    fn owner_log_sources_fan_in_across_owners() {
22527        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22528        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22529            provider,
22530            SubscriberMode::PubSub,
22531            SubscriberConfig::default(),
22532        );
22533        subscriber
22534            .add_interest_owner(
22535                HandlerId::new("pool-a"),
22536                &[ReactiveInterest::Logs(LogInterest {
22537                    provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
22538                    local_matcher: None,
22539                    route_key: None,
22540                })],
22541            )
22542            .expect("register pool-a owner");
22543
22544        let initial_sources = subscriber.stream_sources().expect("initial sources");
22545        assert_eq!(initial_sources.len(), 1);
22546        let pool_a_source = initial_sources[0].clone();
22547        assert!(matches!(
22548            &pool_a_source,
22549            SubscriberStreamSource::PubSubLog { id: 0, .. }
22550        ));
22551
22552        subscriber
22553            .add_interest_owner(
22554                HandlerId::new("pool-b"),
22555                &[ReactiveInterest::Logs(LogInterest {
22556                    provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
22557                    local_matcher: None,
22558                    route_key: None,
22559                })],
22560            )
22561            .expect("register pool-b owner");
22562
22563        let expanded_sources = subscriber.stream_sources().expect("expanded sources");
22564        assert_eq!(
22565            expanded_sources.len(),
22566            1,
22567            "compatible owner filters should share one provider subscription"
22568        );
22569        assert!(
22570            !expanded_sources[0].same_key(&pool_a_source),
22571            "the provider-facing superset changes while owner routing remains exact"
22572        );
22573
22574        subscriber
22575            .remove_interest_owner(&HandlerId::new("pool-b"))
22576            .expect("pool-b should be removed");
22577        let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
22578        assert_eq!(trimmed_sources.len(), 1);
22579        assert!(trimmed_sources[0].same_key(&pool_a_source));
22580    }
22581
22582    #[test]
22583    #[cfg(feature = "reactive-ws")]
22584    fn provider_log_fan_in_respects_address_ceiling() {
22585        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22586        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22587            provider,
22588            SubscriberMode::PubSub,
22589            SubscriberConfig {
22590                max_log_addresses_per_subscription: 2,
22591                ..SubscriberConfig::default()
22592            },
22593        );
22594        for index in 0..5 {
22595            subscriber
22596                .add_interest_owner(
22597                    HandlerId::new(format!("pool-{index}")),
22598                    &[log_interest_for(index + 1)],
22599                )
22600                .expect("register pool owner");
22601        }
22602
22603        let sources = subscriber.stream_sources().expect("stream sources");
22604        assert_eq!(sources.len(), 3);
22605        let mut address_counts: Vec<_> = sources
22606            .iter()
22607            .map(|source| match source {
22608                SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
22609                _ => panic!("expected log source"),
22610            })
22611            .collect();
22612        address_counts.sort_unstable();
22613        assert_eq!(address_counts, vec![1, 2, 2]);
22614    }
22615
22616    #[tokio::test(flavor = "multi_thread")]
22617    #[cfg(feature = "reactive-ws")]
22618    async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
22619        let asserter = Asserter::new();
22620        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22621        asserter.push_success(&vec![rpc_log(false)]);
22622        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22623        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22624        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22625            provider,
22626            SubscriberMode::PubSub,
22627            SubscriberConfig::default(),
22628        );
22629        subscriber
22630            .add_interest_owner_with_backfill(
22631                HandlerId::new("pool-a"),
22632                &[ReactiveInterest::Logs(LogInterest {
22633                    provider_filter: Filter::new()
22634                        .address(Address::repeat_byte(0x42))
22635                        .event_signature(B256::repeat_byte(0x01)),
22636                    local_matcher: None,
22637                    route_key: None,
22638                })],
22639                SubscriberBackfill::range(1, 7),
22640            )
22641            .expect("register pool-a with backfill");
22642
22643        subscriber
22644            .drain_pending_backfills()
22645            .await
22646            .expect("owner backfill should drain");
22647
22648        assert_eq!(subscriber.pending_records.len(), 1);
22649        assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
22650    }
22651
22652    #[tokio::test(flavor = "multi_thread")]
22653    async fn subscriber_streams_poll_ready_sources_round_robin() {
22654        let first_hash = B256::repeat_byte(0x01);
22655        let second_hash = B256::repeat_byte(0x02);
22656        let mut streams = SubscriberStreams::new();
22657        streams.push(
22658            SubscriberStreamSource::PubSubPendingHashes,
22659            stream::iter([
22660                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
22661                SubscriberEvent::<Ethereum>::PendingHash(first_hash),
22662            ])
22663            .boxed(),
22664        );
22665        streams.push(
22666            SubscriberStreamSource::PubSubBlockHeaders,
22667            stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
22668                .boxed(),
22669        );
22670
22671        assert!(matches!(
22672            streams.next().await,
22673            Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
22674        ));
22675        assert!(matches!(
22676            streams.next().await,
22677            Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
22678        ));
22679    }
22680
22681    #[tokio::test(flavor = "multi_thread")]
22682    #[cfg(feature = "reactive-ws")]
22683    async fn owner_updates_ensure_streams_without_full_reset() {
22684        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
22685        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22686            provider,
22687            SubscriberMode::PubSub,
22688            SubscriberConfig::default(),
22689        );
22690        subscriber.chain_id = Some(1);
22691        subscriber
22692            .register_interests(&[ReactiveInterest::PendingTransactions(
22693                PendingTxInterest::default(),
22694            )])
22695            .await
22696            .expect("register base pending interest");
22697        subscriber
22698            .add_interest_owner(
22699                HandlerId::new("headers"),
22700                &[ReactiveInterest::Blocks(BlockInterest::default())],
22701            )
22702            .expect("register header owner");
22703
22704        let mut streams = SubscriberStreams::new();
22705        streams.push(
22706            SubscriberStreamSource::PubSubPendingHashes,
22707            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22708        );
22709        streams.push(
22710            SubscriberStreamSource::PubSubBlockHeaders,
22711            stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22712        );
22713        subscriber.state = AlloySubscriberState::Active(streams);
22714
22715        subscriber
22716            .remove_interest_owner(&HandlerId::new("headers"))
22717            .expect("header owner should be removed");
22718        assert!(matches!(
22719            &subscriber.state,
22720            AlloySubscriberState::Active(streams) if streams.len() == 2
22721        ));
22722
22723        subscriber
22724            .ensure_streams()
22725            .await
22726            .expect("pure removal reconciliation should not touch provider");
22727
22728        assert!(matches!(
22729            &subscriber.state,
22730            AlloySubscriberState::Active(streams)
22731                if streams.len() == 1
22732                    && streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
22733                    && !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
22734        ));
22735
22736        subscriber
22737            .add_interest_owner(
22738                HandlerId::new("headers"),
22739                &[ReactiveInterest::Blocks(BlockInterest::default())],
22740            )
22741            .expect("re-add header owner");
22742        assert!(matches!(
22743            &subscriber.state,
22744            AlloySubscriberState::Active(streams) if streams.len() == 1
22745        ));
22746    }
22747
22748    #[tokio::test(flavor = "multi_thread")]
22749    #[cfg(feature = "reactive-polling")]
22750    async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
22751        let asserter = Asserter::new();
22752        asserter.push_success(&U256::from(1));
22753        asserter.push_failure_msg("second filter connection failed");
22754        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
22755        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22756            provider,
22757            SubscriberMode::Polling,
22758            SubscriberConfig {
22759                max_log_addresses_per_subscription: 1,
22760                ..SubscriberConfig::default()
22761            },
22762        );
22763        subscriber.chain_id = Some(1);
22764        subscriber
22765            .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
22766            .await
22767            .expect("register two independently connected filters");
22768
22769        let error = subscriber
22770            .ensure_streams()
22771            .await
22772            .expect_err("second provider connection is forced to fail");
22773        assert!(matches!(error, SubscriberError::Provider(_)));
22774        assert!(subscriber.sources_dirty);
22775        let retained_streams = match &subscriber.state {
22776            AlloySubscriberState::Active(streams) => Some(streams.len()),
22777            AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
22778        };
22779        assert_eq!(
22780            retained_streams,
22781            Some(1),
22782            "first connection must survive later error {error:?}; revision {}",
22783            subscriber.stream_revision
22784        );
22785
22786        asserter.push_success(&U256::from(2));
22787        subscriber
22788            .ensure_streams()
22789            .await
22790            .expect("retry connects only the missing source");
22791        assert!(!subscriber.sources_dirty);
22792        assert!(matches!(
22793            &subscriber.state,
22794            AlloySubscriberState::Active(streams) if streams.len() == 2
22795        ));
22796        assert!(asserter.read_q().is_empty());
22797    }
22798
22799    #[tokio::test(flavor = "multi_thread")]
22800    #[cfg(feature = "reactive-ws")]
22801    async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
22802        let asserter = Asserter::new();
22803        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
22804        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
22805            provider,
22806            SubscriberMode::PubSub,
22807            SubscriberConfig::default(),
22808        );
22809        subscriber.chain_id = Some(1);
22810        subscriber
22811            .register_interests(&[log_interest_for(0x43)])
22812            .await
22813            .expect("register log source");
22814        let source = subscriber
22815            .stream_sources()
22816            .expect("one desired source")
22817            .pop()
22818            .expect("log source");
22819        let SubscriberStreamSource::PubSubLog { id, .. } = source else {
22820            panic!("expected pubsub log source")
22821        };
22822        subscriber.last_seen_log_blocks.insert(id, 6);
22823
22824        {
22825            let source = SubscriberStreamSource::PubSubLog {
22826                id,
22827                filter: subscriber
22828                    .log_stream_filters()
22829                    .pop()
22830                    .expect("provider filter"),
22831            };
22832            let interrupted = async {
22833                subscriber.install_source_stream(
22834                    source.clone(),
22835                    stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
22836                );
22837                subscriber.queue_source_backfill(source);
22838                subscriber.sources_dirty = true;
22839                futures::future::pending::<()>().await;
22840            };
22841            futures::pin_mut!(interrupted);
22842            poll_fn(|cx| {
22843                assert!(interrupted.as_mut().poll(cx).is_pending());
22844                std::task::Poll::Ready(())
22845            })
22846            .await;
22847        }
22848
22849        assert_eq!(subscriber.pending_source_backfills.len(), 1);
22850        assert!(matches!(
22851            &subscriber.state,
22852            AlloySubscriberState::Active(streams) if streams.len() == 1
22853        ));
22854
22855        asserter.push_success(&7u64);
22856        asserter.push_success(&Vec::<Log>::new());
22857        subscriber
22858            .ensure_streams()
22859            .await
22860            .expect("retry completes only the pending historical window");
22861
22862        assert!(subscriber.pending_source_backfills.is_empty());
22863        assert!(!subscriber.sources_dirty);
22864        assert!(matches!(
22865            &subscriber.state,
22866            AlloySubscriberState::Active(streams) if streams.len() == 1
22867        ));
22868        assert!(asserter.read_q().is_empty());
22869    }
22870
22871    // A log interest matching `rpc_log` (address 0x42, topic0 0x01).
22872    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
22873    fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
22874        ReactiveInterest::Logs(LogInterest {
22875            provider_filter: Filter::new()
22876                .address(Address::repeat_byte(0x42))
22877                .event_signature(B256::repeat_byte(0x01)),
22878            local_matcher: None,
22879            route_key: None,
22880        })
22881    }
22882
22883    #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
22884    fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
22885        ReactiveInterest::Logs(LogInterest {
22886            provider_filter: Filter::new().address(Address::repeat_byte(address)),
22887            local_matcher: None,
22888            route_key: None,
22889        })
22890    }
22891
22892    // B1: a transient provider error must not consume the queued backfill — the
22893    // missed window has to survive for the next poll to retry.
22894    #[tokio::test(flavor = "multi_thread")]
22895    #[cfg(feature = "reactive-ws")]
22896    async fn drain_backfill_retains_queue_entry_on_provider_error() {
22897        let asserter = Asserter::new();
22898        asserter.push_failure_msg("rate limited");
22899        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22900        asserter.push_success(&vec![rpc_log(false)]);
22901        asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
22902        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22903        let mut subscriber = AlloySubscriber::new(
22904            provider,
22905            SubscriberMode::PubSub,
22906            SubscriberConfig::default(),
22907        );
22908        subscriber
22909            .add_interest_owner_with_backfill(
22910                HandlerId::new("pool"),
22911                &[log_interest_matching_rpc_log()],
22912                SubscriberBackfill::range(1, 7),
22913            )
22914            .expect("register owner with backfill");
22915        assert_eq!(subscriber.pending_backfills.len(), 1);
22916
22917        let first = subscriber.drain_pending_backfills().await;
22918        assert!(first.is_err(), "provider failure should surface");
22919        assert_eq!(
22920            subscriber.pending_backfills.len(),
22921            1,
22922            "failed fetch must leave the backfill queued for retry"
22923        );
22924        assert!(subscriber.pending_records.is_empty());
22925
22926        subscriber
22927            .drain_pending_backfills()
22928            .await
22929            .expect("retry should succeed");
22930        assert!(subscriber.pending_backfills.is_empty());
22931        assert_eq!(subscriber.pending_records.len(), 1);
22932    }
22933
22934    // B3: a zero-log backfill window still advances the delivery anchor to its
22935    // upper bound, so a later reconnect catches up from the right block.
22936    #[tokio::test(flavor = "multi_thread")]
22937    #[cfg(feature = "reactive-ws")]
22938    async fn drain_backfill_seeds_anchor_on_empty_window() {
22939        let asserter = Asserter::new();
22940        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
22941        asserter.push_success(&Vec::<Log>::new());
22942        asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
22943        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22944        let mut subscriber = AlloySubscriber::new(
22945            provider,
22946            SubscriberMode::PubSub,
22947            SubscriberConfig::default(),
22948        );
22949        subscriber
22950            .add_interest_owner_with_backfill(
22951                HandlerId::new("pool"),
22952                &[log_interest_matching_rpc_log()],
22953                SubscriberBackfill::range(1, 42),
22954            )
22955            .expect("register owner with backfill");
22956
22957        subscriber
22958            .drain_pending_backfills()
22959            .await
22960            .expect("empty backfill should drain");
22961
22962        assert!(subscriber.pending_records.is_empty());
22963        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
22964            .pop()
22965            .unwrap();
22966        assert_eq!(
22967            subscriber.log_anchor(&filter),
22968            Some(42),
22969            "empty window must still seed the anchor at its upper bound"
22970        );
22971    }
22972
22973    // B3 (open-ended): a `from_block`-only backfill resolves its upper bound to
22974    // the provider head and seeds the anchor there.
22975    #[tokio::test(flavor = "multi_thread")]
22976    #[cfg(feature = "reactive-ws")]
22977    async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
22978        let asserter = Asserter::new();
22979        asserter.push_success(&100u64); // get_block_number
22980        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
22981        asserter.push_success(&Vec::<Log>::new()); // get_logs
22982        asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
22983        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
22984        let mut subscriber = AlloySubscriber::new(
22985            provider,
22986            SubscriberMode::PubSub,
22987            SubscriberConfig::default(),
22988        );
22989        subscriber
22990            .add_interest_owner_with_backfill(
22991                HandlerId::new("pool"),
22992                &[log_interest_matching_rpc_log()],
22993                SubscriberBackfill::from_block(10),
22994            )
22995            .expect("register owner with open-ended backfill");
22996
22997        subscriber
22998            .drain_pending_backfills()
22999            .await
23000            .expect("open-ended backfill should drain");
23001
23002        let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
23003            .pop()
23004            .unwrap();
23005        assert_eq!(subscriber.log_anchor(&filter), Some(100));
23006    }
23007
23008    // B2: two owners requesting the same filter shape share exactly one live
23009    // source (and thus one anchor), rather than double-subscribing.
23010    #[test]
23011    #[cfg(feature = "reactive-ws")]
23012    fn duplicate_filters_across_owners_map_to_single_source() {
23013        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23014        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23015            provider,
23016            SubscriberMode::PubSub,
23017            SubscriberConfig::default(),
23018        );
23019        subscriber
23020            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
23021            .expect("register pool-a");
23022        subscriber
23023            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
23024            .expect("register pool-b with identical filter");
23025
23026        assert_eq!(
23027            subscriber.log_stream_filters().len(),
23028            1,
23029            "identical filters across owners must collapse to one"
23030        );
23031        let sources = subscriber.stream_sources().expect("stream sources");
23032        assert_eq!(sources.len(), 1);
23033    }
23034
23035    // B4: removing an owner retires the source-id and anchor bookkeeping for
23036    // filters no other owner references, so long-lived churn cannot leak.
23037    #[test]
23038    #[cfg(feature = "reactive-ws")]
23039    fn owner_removal_prunes_source_ids_and_anchors() {
23040        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23041        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23042            provider,
23043            SubscriberMode::PubSub,
23044            SubscriberConfig::default(),
23045        );
23046        subscriber
23047            .add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
23048            .expect("register pool-a");
23049        subscriber
23050            .add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
23051            .expect("register pool-b");
23052
23053        // Allocate ids and simulate delivery anchors on both.
23054        let _ = subscriber.stream_sources().expect("stream sources");
23055        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23056        let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
23057        let id_a = subscriber.log_source_id(&filter_a);
23058        let id_b = subscriber.log_source_id(&filter_b);
23059        subscriber.last_seen_log_blocks.insert(id_a, 10);
23060        subscriber.last_seen_log_blocks.insert(id_b, 20);
23061        assert_eq!(
23062            subscriber.log_source_ids.len(),
23063            3,
23064            "one provider fan-in id plus two explicitly seeded logical ids"
23065        );
23066
23067        subscriber
23068            .remove_interest_owner(&HandlerId::new("pool-b"))
23069            .expect("remove pool-b");
23070
23071        assert_eq!(
23072            subscriber.log_source_ids.len(),
23073            1,
23074            "pool-b's filter id should be retired"
23075        );
23076        assert!(subscriber.log_source_ids.contains_key(&filter_a));
23077        assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
23078        assert_eq!(
23079            subscriber.last_seen_log_blocks.get(&id_b),
23080            None,
23081            "pool-b's anchor should be pruned"
23082        );
23083    }
23084
23085    // D1: growing an owner's filter set (a new pool on an existing adapter)
23086    // changes the merged filter shape; the new shape must inherit the old
23087    // anchor via an automatic continuity backfill, or logs between the last
23088    // delivery and the new subscription are silently lost.
23089    #[test]
23090    #[cfg(feature = "reactive-ws")]
23091    fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
23092        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23093        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23094            provider,
23095            SubscriberMode::PubSub,
23096            SubscriberConfig::default(),
23097        );
23098        subscriber
23099            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23100            .expect("register amm with pool A");
23101
23102        // Simulate the owner's single merged filter having delivered up to
23103        // block 50.
23104        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23105        let id_a = subscriber.log_source_id(&filter_a);
23106        subscriber.last_seen_log_blocks.insert(id_a, 50);
23107
23108        // Grow the owner to also watch pool B (same block option -> merges into
23109        // one {A,B} filter, a new shape).
23110        subscriber
23111            .add_interest_owner(
23112                HandlerId::new("amm"),
23113                &[log_interest_for(0xaa), log_interest_for(0xbb)],
23114            )
23115            .expect("grow amm to pools A+B");
23116
23117        assert_eq!(
23118            subscriber.pending_backfills.len(),
23119            1,
23120            "the changed merged filter should queue exactly one continuity backfill"
23121        );
23122        let queued = &subscriber.pending_backfills[0];
23123        assert_eq!(queued.owner, Some(HandlerId::new("amm")));
23124        assert_eq!(queued.backfill.start_block(), 50);
23125        assert_eq!(
23126            queued.backfill.end_block(),
23127            None,
23128            "continuity backfill runs open-ended to the current head"
23129        );
23130    }
23131
23132    // D1 negative: replacing an owner's interests with the identical shape must
23133    // NOT re-fetch — the filter kept its anchor and its live stream.
23134    #[test]
23135    #[cfg(feature = "reactive-ws")]
23136    fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
23137        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23138        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23139            provider,
23140            SubscriberMode::PubSub,
23141            SubscriberConfig::default(),
23142        );
23143        subscriber
23144            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23145            .expect("register amm");
23146        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23147        let id_a = subscriber.log_source_id(&filter_a);
23148        subscriber.last_seen_log_blocks.insert(id_a, 50);
23149
23150        subscriber
23151            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23152            .expect("re-register identical interests");
23153
23154        assert!(
23155            subscriber.pending_backfills.is_empty(),
23156            "an unchanged filter shape must not queue continuity backfill"
23157        );
23158    }
23159
23160    // D5 interaction: an explicit open-ended backfill starting at or below the
23161    // owner's prior anchor already covers the continuity window, so no extra
23162    // continuity backfill is queued (no redundant double fetch).
23163    #[test]
23164    #[cfg(feature = "reactive-ws")]
23165    fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
23166        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23167        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23168            provider,
23169            SubscriberMode::PubSub,
23170            SubscriberConfig::default(),
23171        );
23172        subscriber
23173            .add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
23174            .expect("register amm");
23175        let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
23176        let id_a = subscriber.log_source_id(&filter_a);
23177        subscriber.last_seen_log_blocks.insert(id_a, 50);
23178
23179        // Grow with an explicit deep backfill from block 10 (< anchor 50).
23180        subscriber
23181            .add_interest_owner_with_backfill(
23182                HandlerId::new("amm"),
23183                &[log_interest_for(0xaa), log_interest_for(0xbb)],
23184                SubscriberBackfill::from_block(10),
23185            )
23186            .expect("grow amm with explicit deep backfill");
23187
23188        assert_eq!(
23189            subscriber.pending_backfills.len(),
23190            1,
23191            "only the explicit backfill should be queued; continuity is subsumed"
23192        );
23193        assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
23194    }
23195
23196    // The dirty flag gates reconciliation: when nothing changed since the last
23197    // reconcile, `ensure_streams` must not touch the provider or the state.
23198    #[tokio::test(flavor = "multi_thread")]
23199    #[cfg(feature = "reactive-ws")]
23200    async fn ensure_streams_is_noop_when_not_dirty() {
23201        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
23202        let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
23203            provider,
23204            SubscriberMode::PubSub,
23205            SubscriberConfig::default(),
23206        );
23207        // An interest that WOULD require a new block-header source...
23208        subscriber
23209            .add_interest_owner(
23210                HandlerId::new("headers"),
23211                &[ReactiveInterest::Blocks(BlockInterest::default())],
23212            )
23213            .expect("register header owner");
23214        // ...but we mark bookkeeping clean and start from Empty.
23215        subscriber.state = AlloySubscriberState::Empty;
23216        subscriber.sources_dirty = false;
23217
23218        subscriber
23219            .ensure_streams()
23220            .await
23221            .expect("clean reconcile must be a no-op");
23222
23223        assert!(
23224            matches!(subscriber.state, AlloySubscriberState::Empty),
23225            "not-dirty ensure_streams must not connect new sources"
23226        );
23227    }
23228}
23229
23230fn resolve_subscriber_transport(
23231    mode: SubscriberMode,
23232) -> Result<SubscriberTransport, SubscriberError> {
23233    match mode {
23234        SubscriberMode::PubSub => {
23235            #[cfg(feature = "reactive-ws")]
23236            {
23237                Ok(SubscriberTransport::PubSub)
23238            }
23239            #[cfg(not(feature = "reactive-ws"))]
23240            {
23241                Err(SubscriberError::Unsupported(
23242                    "AlloySubscriber pubsub mode requires the reactive-ws feature",
23243                ))
23244            }
23245        }
23246        SubscriberMode::Polling => {
23247            #[cfg(feature = "reactive-polling")]
23248            {
23249                Ok(SubscriberTransport::Polling)
23250            }
23251            #[cfg(not(feature = "reactive-polling"))]
23252            {
23253                Err(SubscriberError::Unsupported(
23254                    "AlloySubscriber polling mode requires the reactive-polling feature",
23255                ))
23256            }
23257        }
23258        SubscriberMode::Auto => resolve_auto_subscriber_transport(),
23259    }
23260}
23261
23262fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
23263    #[cfg(feature = "reactive-ws")]
23264    {
23265        Ok(SubscriberTransport::PubSub)
23266    }
23267
23268    #[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
23269    {
23270        Ok(SubscriberTransport::Polling)
23271    }
23272
23273    #[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
23274    {
23275        Err(SubscriberError::Unsupported(
23276            "AlloySubscriber requires either reactive-ws or reactive-polling",
23277        ))
23278    }
23279}
23280
23281fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
23282    if config.preconfirmations != PreconfirmationMode::Disabled
23283        && config.canonical_head_poll_interval.is_zero()
23284    {
23285        return Err(SubscriberError::InvalidConfig(
23286            "SubscriberConfig::canonical_head_poll_interval must be greater than zero",
23287        ));
23288    }
23289    if config.preconfirmations != PreconfirmationMode::Disabled
23290        && config.canonical_head_request_timeout.is_zero()
23291    {
23292        return Err(SubscriberError::InvalidConfig(
23293            "SubscriberConfig::canonical_head_request_timeout must be greater than zero",
23294        ));
23295    }
23296    if config.preconfirmations != PreconfirmationMode::Disabled
23297        && config.flashblock_poll_interval.is_zero()
23298    {
23299        return Err(SubscriberError::InvalidConfig(
23300            "SubscriberConfig::flashblock_poll_interval must be greater than zero",
23301        ));
23302    }
23303    if config.preconfirmations != PreconfirmationMode::Disabled
23304        && config.max_consecutive_flashblock_poll_failures == 0
23305    {
23306        return Err(SubscriberError::InvalidConfig(
23307            "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
23308        ));
23309    }
23310    if config.preconfirmations != PreconfirmationMode::Disabled
23311        && config.max_pending_transaction_receipts_per_tick == 0
23312    {
23313        return Err(SubscriberError::InvalidConfig(
23314            "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
23315        ));
23316    }
23317    if config.preconfirmations != PreconfirmationMode::Disabled
23318        && config.max_flashblock_rpc_requests_per_second == 0
23319    {
23320        return Err(SubscriberError::InvalidConfig(
23321            "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
23322        ));
23323    }
23324    if config.max_batch_size == 0 {
23325        return Err(SubscriberError::InvalidConfig(
23326            "SubscriberConfig::max_batch_size must be greater than zero",
23327        ));
23328    }
23329    if config.max_log_addresses_per_subscription == 0 {
23330        return Err(SubscriberError::InvalidConfig(
23331            "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
23332        ));
23333    }
23334    if config.max_pending_records == 0 {
23335        return Err(SubscriberError::InvalidConfig(
23336            "SubscriberConfig::max_pending_records must be greater than zero",
23337        ));
23338    }
23339    if config.max_pending_backfills == 0 {
23340        return Err(SubscriberError::InvalidConfig(
23341            "SubscriberConfig::max_pending_backfills must be greater than zero",
23342        ));
23343    }
23344    if config.max_backfill_log_bytes == 0 {
23345        return Err(SubscriberError::InvalidConfig(
23346            "SubscriberConfig::max_backfill_log_bytes must be greater than zero",
23347        ));
23348    }
23349    if config.max_reconcile_requests_in_flight == 0 {
23350        return Err(SubscriberError::InvalidConfig(
23351            "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
23352        ));
23353    }
23354    if config.reconnect.enabled {
23355        if config.reconnect.retry_delay > config.reconnect.max_delay {
23356            return Err(SubscriberError::InvalidConfig(
23357                "SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
23358            ));
23359        }
23360        if matches!(config.reconnect.max_attempts, Some(0)) {
23361            return Err(SubscriberError::InvalidConfig(
23362                "SubscriberReconnectConfig::max_attempts must be greater than zero when set",
23363            ));
23364        }
23365    }
23366    Ok(())
23367}
23368
23369fn validate_supported_interests<N: Network>(
23370    mode: SubscriberMode,
23371    config: &SubscriberConfig,
23372    interests: &[ReactiveInterest<N>],
23373) -> Result<(), SubscriberError> {
23374    let transport = resolve_subscriber_transport(mode)?;
23375
23376    for interest in interests {
23377        match interest {
23378            ReactiveInterest::Logs(_) => {}
23379            ReactiveInterest::PendingTransactions(interest)
23380                if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
23381            ReactiveInterest::PendingTransactions(_) => {
23382                return Err(SubscriberError::Unsupported(
23383                    "AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
23384                ));
23385            }
23386            ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
23387                (SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
23388                (_, BlockInterestMode::FullBlock) => {
23389                    return Err(SubscriberError::Unsupported(
23390                        "AlloySubscriber full block streams are not implemented in this transport slice",
23391                    ));
23392                }
23393                (SubscriberTransport::Polling, BlockInterestMode::Header) => {
23394                    return Err(SubscriberError::Unsupported(
23395                        "AlloySubscriber polling block streams are not implemented in this transport slice",
23396                    ));
23397                }
23398            },
23399        }
23400    }
23401
23402    Ok(())
23403}
23404
23405fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
23406    let mut filters = Vec::new();
23407    for interest in interests {
23408        if let ReactiveInterest::Logs(interest) = interest {
23409            merge_log_subscription_filter(&mut filters, &interest.provider_filter);
23410        }
23411    }
23412    filters
23413}
23414
23415fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
23416    interests.iter().any(|interest| {
23417        matches!(
23418            interest,
23419            ReactiveInterest::Blocks(BlockInterest {
23420                mode: BlockInterestMode::Header,
23421            })
23422        )
23423    })
23424}
23425
23426fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
23427    interests.iter().any(|interest| {
23428        matches!(
23429            interest,
23430            ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
23431        )
23432    })
23433}
23434
23435fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
23436    interests.iter().any(|interest| {
23437        matches!(
23438            interest,
23439            ReactiveInterest::Logs(interest) if interest.matches(log)
23440        )
23441    })
23442}
23443
23444fn validate_owner_backfill_logs(
23445    logs: &[Log],
23446    from_block: u64,
23447    through: &BlockRef,
23448) -> Result<(), SubscriberOwnerError> {
23449    for log in logs {
23450        if log.removed {
23451            return Err(SubscriberOwnerError::InvalidBackfillLog(
23452                "removed log in canonical catch-up",
23453            ));
23454        }
23455        let number = log
23456            .block_number
23457            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23458                "log missing block number",
23459            ))?;
23460        let hash = log
23461            .block_hash
23462            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23463                "log missing block hash",
23464            ))?;
23465        log.transaction_hash
23466            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23467                "log missing transaction hash",
23468            ))?;
23469        log.transaction_index
23470            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23471                "log missing transaction index",
23472            ))?;
23473        log.log_index
23474            .ok_or(SubscriberOwnerError::InvalidBackfillLog(
23475                "log missing log index",
23476            ))?;
23477        if number < from_block || number > through.number {
23478            return Err(SubscriberOwnerError::InvalidBackfillLog(
23479                "log outside requested block range",
23480            ));
23481        }
23482        if number == through.number && hash != through.hash {
23483            return Err(SubscriberOwnerError::InvalidBackfillLog(
23484                "target-block log hash mismatch",
23485            ));
23486        }
23487    }
23488    Ok(())
23489}
23490
23491fn validate_backfill_resource_limits(
23492    logs: &[Log],
23493    max_logs: usize,
23494    max_log_bytes: usize,
23495) -> Result<usize, SubscriberError> {
23496    if logs.len() > max_logs {
23497        return Err(SubscriberError::ResourceExhausted(format!(
23498            "historical response returned {} logs, above the configured limit of {max_logs}",
23499            logs.len()
23500        )));
23501    }
23502    let bytes = logs.iter().fold(0usize, |total, log| {
23503        // Include fixed address/block/transaction/index fields in addition to
23504        // the variable topic and data payload. This is deliberately a stable
23505        // conservative accounting unit rather than Rust heap-layout size.
23506        let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
23507        total
23508            .saturating_add(fixed)
23509            .saturating_add(log.topics().len().saturating_mul(32))
23510            .saturating_add(log.inner.data.data.len())
23511    });
23512    if bytes > max_log_bytes {
23513        return Err(SubscriberError::ResourceExhausted(format!(
23514            "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
23515        )));
23516    }
23517    Ok(bytes)
23518}
23519
23520async fn fetch_provider_block_ref<P, N>(
23521    provider: &P,
23522    number: u64,
23523) -> Result<BlockRef, SubscriberError>
23524where
23525    P: Provider<N> + Send + Sync,
23526    N: Network,
23527{
23528    let block = provider
23529        .get_block_by_number(BlockNumberOrTag::Number(number))
23530        .await
23531        .map_err(provider_error)?
23532        .ok_or_else(|| {
23533            SubscriberError::InvalidBackfill(format!(
23534                "canonical target block {number} is unavailable"
23535            ))
23536        })?;
23537    let header = block.header();
23538    Ok(BlockRef {
23539        number: header.number(),
23540        hash: header.hash(),
23541        parent_hash: Some(header.parent_hash()),
23542        timestamp: Some(header.timestamp()),
23543    })
23544}
23545
23546fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
23547    actual.number == expected.number
23548        && actual.hash == expected.hash
23549        && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
23550        && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
23551}
23552
23553fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
23554    let mut positions = HashMap::new();
23555    let mut block_hashes = HashMap::new();
23556    let mut transaction_hashes = HashMap::new();
23557    let mut transaction_positions = HashMap::new();
23558    let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
23559    for log in logs {
23560        let number = log
23561            .block_number
23562            .expect("individual owner catch-up logs are validated before set validation");
23563        let block_hash = log
23564            .block_hash
23565            .expect("individual owner catch-up logs are validated before set validation");
23566        let transaction_hash = log
23567            .transaction_hash
23568            .expect("individual owner catch-up logs are validated before set validation");
23569        let transaction_index = log
23570            .transaction_index
23571            .expect("individual owner catch-up logs are validated before set validation");
23572        let log_index = log
23573            .log_index
23574            .expect("individual owner catch-up logs are validated before set validation");
23575        if block_hashes
23576            .insert(number, block_hash)
23577            .is_some_and(|prior| prior != block_hash)
23578        {
23579            return Err(SubscriberOwnerError::InvalidBackfillLog(
23580                "conflicting block identity in canonical catch-up",
23581            ));
23582        }
23583        if let Some(previous) = positions.insert((number, log_index), log)
23584            && previous != log
23585        {
23586            return Err(SubscriberOwnerError::InvalidBackfillLog(
23587                "conflicting logs at one canonical block position",
23588            ));
23589        }
23590        let conflicting_transaction = transaction_hashes
23591            .insert((number, transaction_index), transaction_hash)
23592            .is_some_and(|prior| prior != transaction_hash)
23593            || transaction_positions
23594                .insert((number, transaction_hash), transaction_index)
23595                .is_some_and(|prior| prior != transaction_index);
23596        if conflicting_transaction {
23597            return Err(SubscriberOwnerError::InvalidBackfillLog(
23598                "conflicting transaction identity at one canonical block position",
23599            ));
23600        }
23601        ordering
23602            .entry(number)
23603            .or_default()
23604            .push((log_index, transaction_index));
23605    }
23606    for positions in ordering.values_mut() {
23607        positions.sort_unstable();
23608        if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
23609            return Err(SubscriberOwnerError::InvalidBackfillLog(
23610                "transaction and log positions disagree on canonical order",
23611            ));
23612        }
23613    }
23614    Ok(())
23615}
23616
23617fn merged_owner_reconcile_filters<N: Network>(
23618    plans: &[SubscriberOwnerReconcilePlan<N>],
23619    through: u64,
23620) -> Vec<SubscriberOwnerReconcileFilter> {
23621    let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
23622    for plan in plans.iter().filter(|plan| plan.from_block <= through) {
23623        let filters = by_start.entry(plan.from_block).or_default();
23624        filters.extend(
23625            log_filters(&plan.interests)
23626                .into_iter()
23627                .map(|filter| filter.from_block(plan.from_block).to_block(through)),
23628        );
23629    }
23630
23631    let mut chunks = Vec::new();
23632    for (from_block, filters) in by_start {
23633        for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
23634            let mut merged = Vec::new();
23635            for filter in filters {
23636                merge_log_subscription_filter(&mut merged, filter);
23637            }
23638            chunks.extend(
23639                merged
23640                    .into_iter()
23641                    .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
23642            );
23643        }
23644    }
23645    chunks
23646}
23647
23648fn merged_lazy_backfill_filters(
23649    filters: &[Filter],
23650    from_block: u64,
23651    through: u64,
23652) -> Vec<SubscriberOwnerReconcileFilter> {
23653    let mut requests = Vec::new();
23654    for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
23655        let mut merged = Vec::new();
23656        for filter in filters {
23657            merge_log_subscription_filter(
23658                &mut merged,
23659                &filter.clone().from_block(from_block).to_block(through),
23660            );
23661        }
23662        requests.extend(
23663            merged
23664                .into_iter()
23665                .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
23666        );
23667    }
23668    requests
23669}
23670
23671fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
23672    match error {
23673        SubscriberOwnerError::Subscriber(error) => error,
23674        error => SubscriberError::InvalidBackfill(error.to_string()),
23675    }
23676}
23677
23678fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
23679    let mut id = b"alloy-global-backfill-v1".to_vec();
23680    id.extend_from_slice(&backfill.start_block().to_be_bytes());
23681    id.extend_from_slice(&certified.number.to_be_bytes());
23682    id.extend_from_slice(certified.hash.as_slice());
23683    ChainControl::Barrier {
23684        id,
23685        block: Some(certified),
23686    }
23687}
23688
23689async fn fetch_owner_catchup<P, N>(
23690    provider: P,
23691    filters: Vec<SubscriberOwnerReconcileFilter>,
23692    retained: Vec<BlockRef>,
23693    through: BlockRef,
23694    options: SubscriberOwnerCatchupOptions,
23695) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
23696where
23697    P: Provider<N> + Send + Sync,
23698    N: Network,
23699{
23700    if !options.target_preverified {
23701        let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
23702    }
23703    let mut certified_positions = HashSet::new();
23704    for position in retained {
23705        let target_certifies_position = position == through
23706            || (position.number.checked_add(1) == Some(through.number)
23707                && through.parent_hash == Some(position.hash));
23708        if !target_certifies_position && certified_positions.insert(position) {
23709            let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
23710        }
23711    }
23712    let mut logs = Vec::new();
23713    let mut total_log_bytes = 0usize;
23714    let requests = stream::iter(filters.into_iter().map(|filter| {
23715        let provider = &provider;
23716        async move {
23717            let logs = provider
23718                .get_logs(&filter.filter)
23719                .await
23720                .map_err(provider_error)?;
23721            Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
23722        }
23723    }))
23724    .buffer_unordered(options.max_requests_in_flight);
23725    futures::pin_mut!(requests);
23726    while let Some(result) = requests.next().await {
23727        let (from_block, fetched) = result?;
23728        let fetched_bytes =
23729            validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
23730        validate_owner_backfill_logs(&fetched, from_block, &through)?;
23731        if logs.len().saturating_add(fetched.len()) > options.max_logs {
23732            return Err(SubscriberError::ResourceExhausted(format!(
23733                "bulk reconcile returned more than {} logs",
23734                options.max_logs
23735            ))
23736            .into());
23737        }
23738        total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
23739        if total_log_bytes > options.max_log_bytes {
23740            return Err(SubscriberError::ResourceExhausted(format!(
23741                "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
23742                options.max_log_bytes
23743            ))
23744            .into());
23745        }
23746        logs.extend(fetched);
23747    }
23748    validate_owner_backfill_log_set(&logs)?;
23749    let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
23750    Ok(SubscriberOwnerCatchup { logs, certified })
23751}
23752
23753async fn verify_provider_reconcile_target<P, N>(
23754    provider: &P,
23755    expected: &BlockRef,
23756) -> Result<BlockRef, SubscriberOwnerError>
23757where
23758    P: Provider<N> + Send + Sync,
23759    N: Network,
23760{
23761    let block = provider
23762        .get_block_by_number(BlockNumberOrTag::Number(expected.number))
23763        .await
23764        .map_err(provider_error)?
23765        .ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
23766    let header = block.header();
23767    let actual = BlockRef {
23768        number: header.number(),
23769        hash: header.hash(),
23770        parent_hash: Some(header.parent_hash()),
23771        timestamp: Some(header.timestamp()),
23772    };
23773    let exact_parent = expected
23774        .parent_hash
23775        .is_none_or(|parent| Some(parent) == actual.parent_hash);
23776    let exact_timestamp = expected
23777        .timestamp
23778        .is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
23779    if actual.number != expected.number
23780        || actual.hash != expected.hash
23781        || !exact_parent
23782        || !exact_timestamp
23783    {
23784        return Err(SubscriberOwnerError::BlockMismatch {
23785            expected_number: expected.number,
23786            expected_hash: expected.hash,
23787            actual_number: actual.number,
23788            actual_hash: actual.hash,
23789        });
23790    }
23791    Ok(actual)
23792}
23793
23794fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
23795    let context = log_reactive_context(&log);
23796    ReactiveInputRecord::new(
23797        ReactiveInput::Log(log),
23798        ReactiveContext { source, ..context },
23799    )
23800}
23801
23802fn preconfirmed_log_input_record<N: Network>(
23803    log: Log,
23804    flashblock: FlashblockRef,
23805) -> ReactiveInputRecord<N> {
23806    let block = flashblock.block_ref();
23807    let provider = flashblock.provider.clone();
23808    ReactiveInputRecord::new(
23809        ReactiveInput::Log(log.clone()),
23810        ReactiveContext {
23811            chain_id: None,
23812            source: InputSource::Flashblocks,
23813            chain_status: ChainStatus::Preconfirmed {
23814                flashblock: Arc::new(flashblock),
23815            },
23816            block: Some(block),
23817            transaction_index: log.transaction_index,
23818            log_index: log.log_index,
23819        },
23820    )
23821    .with_provider(provider)
23822}
23823
23824fn log_reactive_context(log: &Log) -> ReactiveContext {
23825    let block = match (log.block_hash, log.block_number) {
23826        (Some(hash), Some(number)) => Some(BlockRef {
23827            number,
23828            hash,
23829            parent_hash: None,
23830            timestamp: log.block_timestamp,
23831        }),
23832        _ => None,
23833    };
23834
23835    let chain_status = match (&block, log.removed) {
23836        (Some(block), true) => ChainStatus::Reorged {
23837            dropped_from: *block,
23838        },
23839        (Some(block), false) => ChainStatus::Included {
23840            block: *block,
23841            confirmations: 0,
23842        },
23843        (None, _) => ChainStatus::Pending,
23844    };
23845
23846    ReactiveContext {
23847        chain_id: None,
23848        source: InputSource::Poll,
23849        chain_status,
23850        block,
23851        transaction_index: log.transaction_index,
23852        log_index: log.log_index,
23853    }
23854}
23855
23856fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
23857where
23858    N: Network,
23859{
23860    let block = BlockRef {
23861        number: header.number(),
23862        hash: HeaderResponseTrait::hash(&header),
23863        parent_hash: Some(header.parent_hash()),
23864        timestamp: Some(header.timestamp()),
23865    };
23866    ReactiveInputRecord::new(
23867        ReactiveInput::BlockHeader(header),
23868        ReactiveContext {
23869            chain_id: None,
23870            source: InputSource::Subscription,
23871            chain_status: ChainStatus::Included {
23872                block,
23873                confirmations: 0,
23874            },
23875            block: Some(block),
23876            transaction_index: None,
23877            log_index: None,
23878        },
23879    )
23880}
23881
23882fn pending_hash_input_record<N: Network>(
23883    hash: B256,
23884    source: InputSource,
23885) -> ReactiveInputRecord<N> {
23886    ReactiveInputRecord::new(
23887        ReactiveInput::PendingTxHash(hash),
23888        ReactiveContext {
23889            chain_id: None,
23890            source,
23891            chain_status: ChainStatus::Pending,
23892            block: None,
23893            transaction_index: None,
23894            log_index: None,
23895        },
23896    )
23897}
23898
23899#[cfg(feature = "reactive-ws")]
23900fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
23901    let encoded = serde_json::to_value(filter)
23902        .map_err(|error| SubscriberError::Provider(error.to_string()))?;
23903    let serde_json::Value::Object(mut fields) = encoded else {
23904        return Err(SubscriberError::Provider(
23905            "Alloy log filter did not serialize as an object".into(),
23906        ));
23907    };
23908    fields.retain(|key, _| key == "address" || key == "topics");
23909    Ok(serde_json::Value::Object(fields))
23910}
23911
23912fn provider_error(error: impl fmt::Display) -> SubscriberError {
23913    SubscriberError::Provider(error.to_string())
23914}
23915
23916/// Subscriber error.
23917#[derive(Debug, thiserror::Error)]
23918#[non_exhaustive]
23919pub enum SubscriberError {
23920    /// Invalid subscriber configuration.
23921    #[error("{0}")]
23922    InvalidConfig(&'static str),
23923    /// Requested subscriber behavior is not implemented.
23924    #[error("{0}")]
23925    Unsupported(&'static str),
23926    /// The pinned provider lease reports a different chain identity.
23927    #[error("subscriber chain mismatch: expected {expected}, got {actual}")]
23928    ChainMismatch {
23929        /// Required chain id.
23930        expected: u64,
23931        /// Observed chain id.
23932        actual: u64,
23933    },
23934    /// Provider or transport error.
23935    #[error("provider error: {0}")]
23936    Provider(String),
23937    /// A provider returned malformed, out-of-range, or non-canonical lazy
23938    /// backfill data.
23939    #[error("invalid canonical backfill: {0}")]
23940    InvalidBackfill(String),
23941    /// A configured subscriber memory/concurrency boundary was exceeded.
23942    #[error("subscriber resource limit exceeded: {0}")]
23943    ResourceExhausted(String),
23944}