use std::{
any::Any,
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
fmt,
future::Future,
hash::Hash,
marker::PhantomData,
num::NonZeroU64,
pin::Pin,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use alloy_consensus::{BlockHeader as _, Transaction as _};
use alloy_eips::{BlockId, BlockNumberOrTag};
use alloy_network::{
Ethereum, Network,
primitives::{
BlockResponse as _, HeaderResponse as HeaderResponseTrait,
TransactionResponse as TransactionResponseTrait,
},
};
use alloy_primitives::{Address, B256, Bytes, U256};
use alloy_provider::Provider;
use alloy_rpc_types_eth::{Filter, FilterSet, Log};
#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))]
use futures::{StreamExt, stream};
use futures::{
future::{Either, poll_fn, select, try_join_all},
stream::BoxStream,
};
use crate::{
cache::{AccountProof, BlockStateDiff, EvmCache},
errors::{BlockContextError, StorageFetchResult},
events::{EventDecoder, StateView},
freshness::FreshnessRegistry,
state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReactiveInput<N: Network = Ethereum> {
Log(Log),
BlockHeader(N::HeaderResponse),
FullBlock(N::BlockResponse),
PendingTxHash(B256),
PendingTx(N::TransactionResponse),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReactiveContext {
pub chain_id: Option<u64>,
pub source: InputSource,
pub chain_status: ChainStatus,
pub block: Option<BlockRef>,
pub transaction_index: Option<u64>,
pub log_index: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BlockRef {
pub number: u64,
pub hash: B256,
pub parent_hash: Option<B256>,
pub timestamp: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChainStatus {
Pending,
Included {
block: BlockRef,
confirmations: u64,
},
Safe {
block: BlockRef,
},
Finalized {
block: BlockRef,
},
Reorged {
dropped_from: BlockRef,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputSource {
Batch,
Subscription,
Poll,
Backfill,
Synthetic,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputRef {
Log {
chain_id: Option<u64>,
block_hash: B256,
transaction_hash: B256,
log_index: u64,
},
PendingTx {
chain_id: Option<u64>,
hash: B256,
},
Block {
chain_id: Option<u64>,
hash: B256,
number: u64,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StateEffectQuality {
ExactFromInput,
AppliedWithPendingResync,
ResyncedAuthoritatively,
RequiresRepair,
NoStateEffect,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HandlerId(String);
impl HandlerId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for HandlerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ReportTag {
pub key: String,
pub value: String,
}
impl ReportTag {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}
#[derive(Clone)]
pub struct HookSignal {
pub namespace: Cow<'static, str>,
pub kind: Cow<'static, str>,
pub labels: Vec<ReportTag>,
pub payload: Option<Arc<dyn Any + Send + Sync>>,
}
impl fmt::Debug for HookSignal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HookSignal")
.field("namespace", &self.namespace)
.field("kind", &self.kind)
.field("labels", &self.labels)
.field("payload", &self.payload.as_ref().map(|_| "<payload>"))
.finish()
}
}
#[derive(Clone, Debug)]
pub enum ReactiveEffect {
StateUpdate(StateUpdate),
Resync(ResyncRequest),
Invalidate(InvalidationRequest),
Hook(HookSignal),
Speculative(SpeculativeRequest),
}
#[derive(Clone, Debug)]
pub struct HandlerOutcome {
pub effects: Vec<ReactiveEffect>,
pub quality: StateEffectQuality,
pub tags: Vec<ReportTag>,
}
impl HandlerOutcome {
pub fn empty(quality: StateEffectQuality) -> Self {
Self {
effects: Vec::new(),
quality,
tags: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct ReactiveInputRecord<N: Network = Ethereum> {
pub input: ReactiveInput<N>,
pub context: ReactiveContext,
}
impl<N: Network> ReactiveInputRecord<N> {
pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
Self { input, context }
}
pub fn input_ref(&self) -> InputRef {
input_ref(&self.input, &self.context)
}
}
#[derive(Clone, Debug)]
pub struct ReactiveInputBatch<N: Network = Ethereum> {
records: Vec<ReactiveInputRecord<N>>,
}
impl<N: Network> ReactiveInputBatch<N> {
pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
Self { records }
}
pub fn records(&self) -> &[ReactiveInputRecord<N>] {
&self.records
}
pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
self.records
}
}
pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
fn id(&self) -> HandlerId;
fn interests(&self) -> Vec<ReactiveInterest<N>>;
fn log_route_index(&self) -> Option<LogRouteIndex> {
None
}
fn handle(
&self,
ctx: &ReactiveContext,
input: &ReactiveInput<N>,
state: &dyn StateView,
) -> Result<HandlerOutcome, HandlerError>;
}
pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
fn on_report(&self, report: Arc<ReactiveReport<N>>);
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone)]
pub enum ReactiveInterest<N: Network = Ethereum> {
Logs(LogInterest),
Blocks(BlockInterest),
PendingTransactions(PendingTxInterest<N>),
}
impl<N: Network> fmt::Debug for ReactiveInterest<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
Self::PendingTransactions(interest) => f
.debug_tuple("PendingTransactions")
.field(interest)
.finish(),
}
}
}
#[derive(Clone)]
pub struct LogInterest {
pub provider_filter: Filter,
pub local_matcher: Option<Arc<dyn LogMatcher>>,
pub route_key: Option<RouteKeySpec>,
}
impl LogInterest {
pub fn matches(&self, log: &Log) -> bool {
self.provider_filter.rpc_matches(log)
&& self
.local_matcher
.as_ref()
.is_none_or(|matcher| matcher.matches(log))
}
pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
self.route_key.as_ref().and_then(|spec| spec.extract(log))
}
}
impl fmt::Debug for LogInterest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogInterest")
.field("provider_filter", &self.provider_filter)
.field(
"local_matcher",
&self.local_matcher.as_ref().map(|_| "<matcher>"),
)
.field("route_key", &self.route_key)
.finish()
}
}
pub trait LogMatcher: Send + Sync {
fn matches(&self, log: &Log) -> bool;
}
#[derive(Clone)]
pub enum RouteKeySpec {
EmitterAddress,
Topic {
index: usize,
},
DataSlice {
offset: usize,
len: usize,
},
Custom(Arc<dyn RouteKeyExtractor>),
}
impl RouteKeySpec {
pub fn extract(&self, log: &Log) -> Option<RouteKey> {
match self {
Self::EmitterAddress => Some(RouteKey::Address(log.address())),
Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
Self::DataSlice { offset, len } => {
let data = log.inner.data.data.as_ref();
let end = offset.checked_add(*len)?;
data.get(*offset..end)
.map(|bytes| RouteKey::Bytes(bytes.to_vec()))
}
Self::Custom(extractor) => extractor.extract(log),
}
}
}
impl fmt::Debug for RouteKeySpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmitterAddress => f.write_str("EmitterAddress"),
Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
Self::DataSlice { offset, len } => f
.debug_struct("DataSlice")
.field("offset", offset)
.field("len", len)
.finish(),
Self::Custom(_) => f.write_str("Custom(<extractor>)"),
}
}
}
pub trait RouteKeyExtractor: Send + Sync {
fn extract(&self, log: &Log) -> Option<RouteKey>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum RouteKey {
Address(Address),
Bytes32(B256),
Bytes(Vec<u8>),
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum LogRouteKey {
Emitter(Address),
Topic {
index: usize,
value: B256,
},
DataSlice {
offset: usize,
value: Vec<u8>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogRouteIndex {
keys: Vec<LogRouteKey>,
}
impl LogRouteIndex {
pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
let mut keys = vec![primary];
for key in additional {
if !keys.contains(&key) {
keys.push(key);
}
}
Self { keys }
}
pub fn single(key: LogRouteKey) -> Self {
Self { keys: vec![key] }
}
pub fn keys(&self) -> &[LogRouteKey] {
&self.keys
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReactiveLogRoute {
pub handler_id: HandlerId,
pub route_key: Option<RouteKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BlockInterest {
pub mode: BlockInterestMode,
}
impl Default for BlockInterest {
fn default() -> Self {
Self {
mode: BlockInterestMode::Header,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BlockInterestMode {
Header,
FullBlock,
}
#[derive(Clone)]
pub struct PendingTxInterest<N: Network = Ethereum> {
pub full_transactions: bool,
pub from: AddressMatcher,
pub to: AddressMatcher,
pub selectors: SelectorMatcher,
pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
}
impl<N: Network> Default for PendingTxInterest<N> {
fn default() -> Self {
Self {
full_transactions: false,
from: AddressMatcher::Any,
to: AddressMatcher::Any,
selectors: SelectorMatcher::Any,
local_matcher: None,
}
}
}
impl<N: Network> PendingTxInterest<N> {
fn matches_hash_only(&self) -> bool {
!self.full_transactions
&& self.from.is_any()
&& self.to.is_any()
&& self.selectors.is_any()
&& self.local_matcher.is_none()
}
fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
self.from.matches(tx.from())
&& self.to.matches_option(tx.to())
&& self.selectors.matches(tx.input())
&& self
.local_matcher
.as_ref()
.is_none_or(|matcher| matcher.matches(tx))
}
}
impl<N: Network> fmt::Debug for PendingTxInterest<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PendingTxInterest")
.field("full_transactions", &self.full_transactions)
.field("from", &self.from)
.field("to", &self.to)
.field("selectors", &self.selectors)
.field(
"local_matcher",
&self.local_matcher.as_ref().map(|_| "<matcher>"),
)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum AddressMatcher {
Any,
Exact(Address),
AnyOf(Vec<Address>),
}
impl AddressMatcher {
pub fn is_any(&self) -> bool {
matches!(self, Self::Any)
}
pub fn matches(&self, address: Address) -> bool {
match self {
Self::Any => true,
Self::Exact(expected) => *expected == address,
Self::AnyOf(addresses) => addresses.contains(&address),
}
}
pub fn matches_option(&self, address: Option<Address>) -> bool {
match (self, address) {
(Self::Any, _) => true,
(_, Some(address)) => self.matches(address),
_ => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SelectorMatcher {
Any,
AnyOf(Vec<[u8; 4]>),
}
impl SelectorMatcher {
pub fn is_any(&self) -> bool {
matches!(self, Self::Any)
}
pub fn matches(&self, input: &Bytes) -> bool {
match self {
Self::Any => true,
Self::AnyOf(selectors) => input
.get(..4)
.and_then(|bytes| bytes.try_into().ok())
.is_some_and(|selector| selectors.contains(&selector)),
}
}
}
pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
fn matches(&self, tx: &N::TransactionResponse) -> bool;
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum TrackingPolicy {
Slots {
slots: Vec<U256>,
},
WholeAccount,
Scalars,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RootGateCadence {
EveryNBlocks(NonZeroU64),
Disabled,
}
impl RootGateCadence {
pub fn every_n_blocks(n: u64) -> Self {
Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1"))
}
}
impl Default for RootGateCadence {
fn default() -> Self {
Self::every_n_blocks(16)
}
}
#[derive(Clone, Debug)]
struct TrackedRoot {
last_root: B256,
last_block: u64,
balance: U256,
nonce: u64,
code_hash: B256,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResyncRequest {
pub id: ResyncId,
pub reason: ResyncReason,
pub block: ResyncBlock,
pub targets: Vec<ResyncTarget>,
pub priority: ResyncPriority,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ResyncId(String);
impl ResyncId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ResyncReason {
HandlerRequested,
SkippedStateEffect,
MissedBlockRange,
RootMoved,
Custom(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ResyncBlock {
Latest,
Safe,
Finalized,
Number(u64),
Hash {
number: u64,
hash: B256,
require_canonical: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ResyncTarget {
StorageSlot {
address: Address,
slot: U256,
},
StorageSlots {
address: Address,
slots: Vec<U256>,
},
Account {
address: Address,
fields: AccountFieldMask,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct AccountFieldMask {
pub balance: bool,
pub nonce: bool,
pub code: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ResyncPriority {
Low,
#[default]
Normal,
High,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidationRequest {
pub scope: PurgeScope,
pub address: Address,
pub reason: InvalidationReason,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum InvalidationReason {
HandlerRequested,
Reorg,
Custom(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpeculativeRequest {
pub id: SpeculativeId,
pub input_ref: InputRef,
pub labels: Vec<ReportTag>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SpeculativeId(String);
impl SpeculativeId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReactiveConfig {
pub hook_backpressure: HookBackpressure,
pub journal_depth: usize,
}
impl Default for ReactiveConfig {
fn default() -> Self {
Self {
hook_backpressure: HookBackpressure::Block,
journal_depth: 64,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HookBackpressure {
Block,
DropNewest,
DropOldest,
Error,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum CacheHealth {
#[default]
Healthy,
Degraded {
since_block: u64,
},
Unhealthy {
since_block: u64,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CacheMetricsSnapshot {
pub deep_reorgs: u64,
pub reorgs_recovered: u64,
pub resync_requests: u64,
pub resync_failures: u64,
pub missed_ranges: u64,
pub coverage_gaps: u64,
pub pending_contamination: u64,
pub stale_verdicts: u64,
}
#[derive(Debug, Default)]
struct CacheMetrics {
deep_reorgs: AtomicU64,
reorgs_recovered: AtomicU64,
resync_requests: AtomicU64,
resync_failures: AtomicU64,
missed_ranges: AtomicU64,
coverage_gaps: AtomicU64,
pending_contamination: AtomicU64,
stale_verdicts: AtomicU64,
}
impl CacheMetrics {
fn snapshot(&self) -> CacheMetricsSnapshot {
CacheMetricsSnapshot {
deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed),
reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed),
resync_requests: self.resync_requests.load(Ordering::Relaxed),
resync_failures: self.resync_failures.load(Ordering::Relaxed),
missed_ranges: self.missed_ranges.load(Ordering::Relaxed),
coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed),
pending_contamination: self.pending_contamination.load(Ordering::Relaxed),
stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed),
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum ReactiveReport<N: Network = Ethereum> {
Input(InputReport<N>),
Decoded(DecodedReport<N>),
Applied(AppliedReport<N>),
Resynced(ResyncReport),
BlockCommitted(BlockReport<N>),
Reorg(ReorgReport<N>),
MissedBlockRange(MissedRangeReport<N>),
Health(HealthReport<N>),
CoverageGap(CoverageGapReport<N>),
Error(ReactiveErrorReport<N>),
}
#[derive(Clone, Debug)]
pub struct InputReport<N: Network = Ethereum> {
pub input_ref: InputRef,
pub context: ReactiveContext,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct DecodedReport<N: Network = Ethereum> {
pub input_ref: InputRef,
pub handler_ids: Vec<HandlerId>,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct AppliedReport<N: Network = Ethereum> {
pub input_ref: InputRef,
pub handler_id: HandlerId,
pub quality: StateEffectQuality,
pub tags: Vec<ReportTag>,
pub diff: StateDiff,
pub state_updates: Vec<StateUpdate>,
pub invalidations: Vec<InvalidationRequest>,
pub resyncs: Vec<ResyncRequest>,
pub speculative: Vec<SpeculativeRequest>,
pub hook_signals: Vec<HookSignal>,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResyncReport {
pub requested: Vec<ResyncRequest>,
pub state_updates: Vec<StateUpdate>,
pub diff: StateDiff,
pub failed: Vec<ResyncFailure>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResyncFailure {
pub request_id: ResyncId,
pub block: ResyncBlock,
pub target: ResyncTarget,
pub kind: ResyncFailureKind,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ResyncFailureKind {
MissingStorageFetcher,
StorageFetchFailed,
StorageFetchOmitted,
MissingAccountFetcher,
AccountFetchFailed,
AccountFetchOmitted,
}
#[derive(Clone, Debug)]
pub struct BlockReport<N: Network = Ethereum> {
pub block: Option<BlockRef>,
pub inputs: Vec<InputRef>,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct ReorgReport<N: Network = Ethereum> {
pub dropped: Option<BlockRef>,
pub dropped_blocks: Vec<BlockRef>,
pub dropped_inputs: Vec<InputRef>,
pub rollback_updates: Vec<StateUpdate>,
pub rollback_diff: StateDiff,
pub purge_updates: Vec<StateUpdate>,
pub purge_diff: StateDiff,
pub canceled_resyncs: Vec<ResyncRequest>,
pub reason: ReorgReason,
pub _network: PhantomData<N>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReorgReason {
RemovedLog,
ReorgedInput,
ParentMismatch,
}
#[derive(Clone, Debug)]
pub struct MissedRangeReport<N: Network = Ethereum> {
pub from: u64,
pub to: u64,
pub block: u64,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct HealthReport<N: Network = Ethereum> {
pub from: CacheHealth,
pub to: CacheHealth,
pub block: Option<u64>,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct CoverageGapReport<N: Network = Ethereum> {
pub address: Address,
pub block: u64,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct ReactiveErrorReport<N: Network = Ethereum> {
pub input_ref: Option<InputRef>,
pub message: String,
pub _network: PhantomData<N>,
}
#[derive(Clone, Debug)]
pub struct ReactiveBatchReport<N: Network = Ethereum> {
pub applied: Vec<AppliedReport<N>>,
pub resyncs: Vec<ResyncRequest>,
pub speculative: Vec<SpeculativeRequest>,
pub reports: Vec<Arc<ReactiveReport<N>>>,
}
impl<N: Network> Default for ReactiveBatchReport<N> {
fn default() -> Self {
Self {
applied: Vec::new(),
resyncs: Vec::new(),
speculative: Vec::new(),
reports: Vec::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HandlerError {
message: String,
}
impl HandlerError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for HandlerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.message.fmt(f)
}
}
impl std::error::Error for HandlerError {}
impl From<String> for HandlerError {
fn from(message: String) -> Self {
Self::new(message)
}
}
impl From<&str> for HandlerError {
fn from(message: &str) -> Self {
Self::new(message)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ReactiveError {
#[error("handler `{handler_id}` failed: {source}")]
HandlerFailed {
handler_id: HandlerId,
source: HandlerError,
},
#[error(
"conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`"
)]
ConflictingEffects {
input_ref: Box<InputRef>,
target: Box<EffectTarget>,
first: HandlerId,
second: HandlerId,
},
#[error(
"pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`"
)]
InvalidPendingEffect {
input_ref: Box<InputRef>,
handler_id: HandlerId,
effect_kind: &'static str,
},
#[error(transparent)]
Register(#[from] RegisterError),
}
#[derive(Debug, thiserror::Error)]
pub enum RegisterError {
#[error("handler id `{0}` is already registered")]
DuplicateHandler(HandlerId),
}
#[derive(Debug, thiserror::Error)]
pub enum ReactiveEngineRegisterError {
#[error(transparent)]
Register(#[from] RegisterError),
#[error(transparent)]
Subscriber(#[from] SubscriberError),
}
#[derive(Debug, thiserror::Error)]
pub enum ReactiveEngineError {
#[error(transparent)]
Subscriber(#[from] SubscriberError),
#[error(transparent)]
Runtime(#[from] ReactiveError),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum EffectTarget {
StorageSlot {
address: Address,
slot: U256,
},
AccountBalance {
address: Address,
},
AccountNonce {
address: Address,
},
AccountCode {
address: Address,
},
MaskedStorageSlot {
address: Address,
slot: U256,
mask: U256,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum AbsoluteValue {
U256(U256),
U64(u64),
Bytes(Bytes),
}
pub struct ReactiveRuntime<N: Network = Ethereum> {
registry: ReactiveRegistry<N>,
hooks: Vec<Arc<dyn ReactiveHook<N>>>,
config: ReactiveConfig,
journal: VecDeque<BlockJournal<N>>,
pending_resyncs: Vec<ResyncRequest>,
health: CacheHealth,
metrics: CacheMetrics,
freshness: Option<FreshnessRegistry>,
tracking: HashMap<Address, TrackingPolicy>,
tracked_roots: HashMap<Address, TrackedRoot>,
root_gate_cadence: RootGateCadence,
last_gate_block: Option<u64>,
touched_since_gate: HashSet<Address>,
}
#[derive(Clone, Debug)]
struct BlockJournal<N: Network = Ethereum> {
block: BlockRef,
inputs: Vec<InputRef>,
applied: Vec<AppliedReport<N>>,
resynced: Vec<ResyncReport>,
}
pub struct ReactiveRegistry<N: Network = Ethereum> {
handlers: BTreeMap<u128, RegisteredHandler<N>>,
handler_positions: HashMap<HandlerId, u128>,
next_handler_position: u128,
indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
fallback_log_handlers: BTreeSet<u128>,
data_slice_shapes: HashMap<(usize, usize), usize>,
}
struct RegisteredHandler<N: Network = Ethereum> {
id: HandlerId,
handler: Arc<dyn ReactiveHandler<N>>,
interests: Vec<ReactiveInterest<N>>,
has_log_interests: bool,
log_route_index: Option<LogRouteIndex>,
}
impl<N: Network> Default for ReactiveRegistry<N> {
fn default() -> Self {
Self::new()
}
}
impl<N: Network> ReactiveRegistry<N> {
pub fn new() -> Self {
Self {
handlers: BTreeMap::new(),
handler_positions: HashMap::new(),
next_handler_position: 0,
indexed_log_handlers: HashMap::new(),
fallback_log_handlers: BTreeSet::new(),
data_slice_shapes: HashMap::new(),
}
}
pub fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), RegisterError> {
let id = handler.id();
if self.handler_positions.contains_key(&id) {
return Err(RegisterError::DuplicateHandler(id));
}
let interests = handler.interests();
let has_log_interests = interests
.iter()
.any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
let log_route_index = handler.log_route_index();
if self.next_handler_position == u128::MAX {
self.compact_handler_positions();
}
let position = self.next_handler_position;
self.next_handler_position += 1;
self.handler_positions.insert(id.clone(), position);
if let Some(index) = &log_route_index {
for key in index.keys() {
if let LogRouteKey::DataSlice { offset, value } = key {
*self
.data_slice_shapes
.entry((*offset, value.len()))
.or_default() += 1;
}
self.indexed_log_handlers
.entry(key.clone())
.or_default()
.insert(position);
}
} else if has_log_interests {
self.fallback_log_handlers.insert(position);
}
self.handlers.insert(
position,
RegisteredHandler {
id,
handler,
interests,
has_log_interests,
log_route_index,
},
);
Ok(())
}
pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
let position = self.handler_positions.remove(id)?;
let registered = self.handlers.remove(&position)?;
if let Some(index) = ®istered.log_route_index {
for key in index.keys() {
let remove_bucket = self
.indexed_log_handlers
.get_mut(key)
.is_some_and(|owners| {
owners.remove(&position);
owners.is_empty()
});
if remove_bucket {
self.indexed_log_handlers.remove(key);
}
if let LogRouteKey::DataSlice { offset, value } = key {
let shape = (*offset, value.len());
let remove_shape =
self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
*count -= 1;
*count == 0
});
if remove_shape {
self.data_slice_shapes.remove(&shape);
}
}
}
} else {
self.fallback_log_handlers.remove(&position);
}
Some(registered.handler)
}
pub fn contains_handler(&self, id: &HandlerId) -> bool {
self.handler_positions.contains_key(id)
}
pub fn handler_ids(&self) -> Vec<HandlerId> {
self.handlers
.values()
.map(|handler| handler.id.clone())
.collect()
}
pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.handler_positions
.get(id)
.and_then(|position| self.handlers.get(position))
.map(|registered| registered.interests.as_slice())
}
pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
self.handlers
.values()
.flat_map(|handler| handler.interests.clone())
.collect()
}
pub fn log_subscription_filters(&self) -> Vec<Filter> {
let mut filters = Vec::new();
for interest in self.log_interests() {
merge_log_subscription_filter(&mut filters, &interest.provider_filter);
}
filters
}
pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
self.log_handler_candidates(log)
.into_iter()
.filter_map(|handler| handler.route_log(log))
.collect()
}
fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
let mut indexed_positions = Vec::new();
if let Some(indexed) = self
.indexed_log_handlers
.get(&LogRouteKey::Emitter(log.address()))
{
indexed_positions.extend(indexed.iter().copied());
}
for (index, value) in log.topics().iter().copied().enumerate() {
if let Some(indexed) = self
.indexed_log_handlers
.get(&LogRouteKey::Topic { index, value })
{
indexed_positions.extend(indexed.iter().copied());
}
}
let data = log.inner.data.data.as_ref();
for &(offset, len) in self.data_slice_shapes.keys() {
let Some(end) = offset.checked_add(len) else {
continue;
};
let Some(value) = data.get(offset..end) else {
continue;
};
if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
offset,
value: value.to_vec(),
}) {
indexed_positions.extend(indexed.iter().copied());
}
}
if indexed_positions.is_empty() {
if self.fallback_log_handlers.is_empty() {
return Vec::new();
}
if !self.indexed_log_handlers.is_empty() {
return self
.fallback_log_handlers
.iter()
.filter_map(|position| self.handlers.get(position))
.collect();
}
return self
.handlers
.values()
.filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
.collect();
}
indexed_positions.extend(self.fallback_log_handlers.iter().copied());
indexed_positions.sort_unstable();
indexed_positions.dedup();
indexed_positions
.into_iter()
.filter_map(|position| self.handlers.get(&position))
.collect()
}
fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
self.handlers.values()
}
fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
self.handlers.values().flat_map(|handler| {
handler
.interests
.iter()
.filter_map(|interest| match interest {
ReactiveInterest::Logs(interest) => Some(interest),
ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
})
})
}
fn compact_handler_positions(&mut self) {
let handlers = std::mem::take(&mut self.handlers);
self.handler_positions.clear();
self.indexed_log_handlers.clear();
self.fallback_log_handlers.clear();
self.data_slice_shapes.clear();
for (position, (_, handler)) in handlers.into_iter().enumerate() {
let position = position as u128;
self.handler_positions.insert(handler.id.clone(), position);
if let Some(index) = &handler.log_route_index {
for key in index.keys() {
if let LogRouteKey::DataSlice { offset, value } = key {
*self
.data_slice_shapes
.entry((*offset, value.len()))
.or_default() += 1;
}
self.indexed_log_handlers
.entry(key.clone())
.or_default()
.insert(position);
}
} else if handler.has_log_interests {
self.fallback_log_handlers.insert(position);
}
self.handlers.insert(position, handler);
}
self.next_handler_position = self.handlers.len() as u128;
}
}
impl<N: Network> ReactiveRuntime<N> {
pub fn new(config: ReactiveConfig) -> Self {
Self {
registry: ReactiveRegistry::new(),
hooks: Vec::new(),
config,
journal: VecDeque::new(),
pending_resyncs: Vec::new(),
health: CacheHealth::Healthy,
metrics: CacheMetrics::default(),
freshness: None,
tracking: HashMap::new(),
tracked_roots: HashMap::new(),
root_gate_cadence: RootGateCadence::default(),
last_gate_block: None,
touched_since_gate: HashSet::new(),
}
}
pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) {
self.tracking.insert(address, policy);
self.tracked_roots.remove(&address);
}
pub fn untrack_account(&mut self, address: Address) -> bool {
self.tracked_roots.remove(&address);
self.tracking.remove(&address).is_some()
}
pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) {
self.root_gate_cadence = cadence;
self.last_gate_block = None;
self.touched_since_gate.clear();
}
pub fn root_gate_cadence(&self) -> RootGateCadence {
self.root_gate_cadence
}
pub fn enable_freshness_stamping(&mut self) {
if self.freshness.is_none() {
self.freshness = Some(FreshnessRegistry::new());
}
}
pub fn freshness(&self) -> Option<&FreshnessRegistry> {
self.freshness.as_ref()
}
pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> {
self.freshness.as_mut()
}
pub fn health(&self) -> CacheHealth {
self.health
}
pub fn metrics(&self) -> CacheMetricsSnapshot {
self.metrics.snapshot()
}
pub fn reset_health(&mut self) {
self.health = CacheHealth::Healthy;
}
fn escalate_trust(&mut self, block: u64) -> Option<Arc<ReactiveReport<N>>> {
let to = match self.health {
CacheHealth::Healthy => CacheHealth::Degraded { since_block: block },
CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block },
CacheHealth::Unhealthy { .. } => return None,
};
self.transition_health(to, Some(block))
}
fn transition_health(
&mut self,
to: CacheHealth,
block: Option<u64>,
) -> Option<Arc<ReactiveReport<N>>> {
if to == self.health {
return None;
}
let from = self.health;
self.health = to;
Some(Arc::new(ReactiveReport::Health(HealthReport {
from,
to,
block,
_network: PhantomData,
})))
}
pub fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), RegisterError> {
self.registry.register_handler(handler)
}
pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
self.registry.unregister_handler(id)
}
pub fn contains_handler(&self, id: &HandlerId) -> bool {
self.registry.contains_handler(id)
}
pub fn handler_ids(&self) -> Vec<HandlerId> {
self.registry.handler_ids()
}
pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.registry.handler_interests(id)
}
pub fn last_canonical_block(&self) -> Option<BlockRef> {
self.journal.back().map(|entry| entry.block.clone())
}
pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
self.journal.iter().any(|entry| {
entry
.applied
.iter()
.any(|applied| &applied.handler_id == handler_id)
})
}
pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
self.journal
.iter()
.flat_map(|entry| entry.applied.iter())
.map(|applied| applied.handler_id.clone())
.collect()
}
pub fn pending_resyncs(&self) -> &[ResyncRequest] {
&self.pending_resyncs
}
pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
}
pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
if ids.is_empty() {
return Vec::new();
}
let ids: HashSet<&ResyncId> = ids.iter().collect();
let mut cancelled = Vec::new();
self.pending_resyncs.retain(|request| {
if ids.contains(&request.id) {
cancelled.push(request.clone());
false
} else {
true
}
});
cancelled
}
pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
let mut cancelled = Vec::new();
self.pending_resyncs.retain_mut(|request| {
let (matching, remaining): (Vec<_>, Vec<_>) = request
.targets
.drain(..)
.partition(|target| resync_target_address(target) == address);
request.targets = remaining;
if !matching.is_empty() {
cancelled.push(ResyncRequest {
id: request.id.clone(),
reason: request.reason.clone(),
block: request.block.clone(),
targets: matching,
priority: request.priority,
});
}
!request.targets.is_empty()
});
cancelled
}
pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
self.hooks.push(hook);
Ok(())
}
pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
self.registry.interests()
}
pub fn ingest_batch(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
let batch_report = self.ingest_batch_direct(cache, batch)?;
self.dispatch_reports(&batch_report.reports);
let _ = &self.config;
Ok(batch_report)
}
pub fn ingest_batch_with_resync(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
let mut batch_report = self.ingest_batch_direct(cache, batch)?;
if !batch_report.resyncs.is_empty() {
let resync_report = execute_resync_requests(cache, &batch_report.resyncs);
let unique_requests = resync_report
.requested
.iter()
.map(|request| &request.id)
.collect::<HashSet<_>>()
.len();
self.metrics
.resync_requests
.fetch_add(unique_requests as u64, Ordering::Relaxed);
self.metrics
.resync_failures
.fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed);
self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id));
self.record_journal_resync(&resync_report);
batch_report
.reports
.push(Arc::new(ReactiveReport::Resynced(resync_report)));
}
self.dispatch_reports(&batch_report.reports);
let _ = &self.config;
Ok(batch_report)
}
fn ingest_batch_direct(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
let records = sort_records(dedupe_records(batch.into_records()));
let mut batch_report = ReactiveBatchReport::default();
let mut reports_to_dispatch = Vec::new();
let mut touched_addrs: HashSet<Address> = HashSet::new();
let mut canonical_batch_block: Option<u64> = None;
for record in records {
let input_ref = record.input_ref();
reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
input_ref,
context: record.context.clone(),
_network: PhantomData,
})));
if let Some(reorg_report) =
self.recover_for_canonical_input(cache, &record, &mut reports_to_dispatch)
{
self.metrics
.reorgs_recovered
.fetch_add(1, Ordering::Relaxed);
remove_canceled_resyncs_from_batch(
&mut batch_report.resyncs,
&reorg_report.canceled_resyncs,
);
reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
}
if let Some(reorg_report) =
self.recover_for_reorged_input(cache, &record, &mut reports_to_dispatch)
{
self.metrics
.reorgs_recovered
.fetch_add(1, Ordering::Relaxed);
remove_canceled_resyncs_from_batch(
&mut batch_report.resyncs,
&reorg_report.canceled_resyncs,
);
reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
continue;
}
if let Some(block) = canonical_record_block(&record) {
canonical_batch_block = Some(block.number);
self.record_journal_input(block, input_ref);
}
if let Some(Err(err)) = advance_block_for_canonical_record(cache, &record) {
reports_to_dispatch.push(Arc::new(ReactiveReport::Error(ReactiveErrorReport {
input_ref: Some(input_ref),
message: err.to_string(),
_network: PhantomData,
})));
}
let executions = self.execute_handlers(cache, &record, input_ref)?;
if executions.is_empty() {
continue;
}
reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport {
input_ref,
handler_ids: executions
.iter()
.map(|execution| execution.handler_id.clone())
.collect(),
_network: PhantomData,
})));
detect_conflicts(input_ref, &executions)?;
let canonical_block_number = canonical_record_block(&record).map(|block| block.number);
for execution in executions {
let diff = if execution.state_updates.is_empty() {
StateDiff::default()
} else {
cache.apply_updates(&execution.state_updates)
};
batch_report
.resyncs
.extend(execution.resyncs.iter().cloned());
self.pending_resyncs
.extend(execution.resyncs.iter().cloned());
batch_report
.speculative
.extend(execution.speculative.iter().cloned());
let applied = AppliedReport {
input_ref,
handler_id: execution.handler_id,
quality: execution.quality,
tags: execution.tags,
diff,
state_updates: execution.state_updates,
invalidations: execution.invalidations,
resyncs: execution.resyncs,
speculative: execution.speculative,
hook_signals: execution.hook_signals,
_network: PhantomData,
};
if let (Some(number), Some(registry)) =
(canonical_block_number, self.freshness.as_mut())
{
for change in &applied.diff.slots {
registry.valid_through_slot(change.address, change.slot, number);
}
}
collect_diff_addresses(&applied.diff, &mut touched_addrs);
let report = Arc::new(ReactiveReport::Applied(applied.clone()));
reports_to_dispatch.push(report);
if let Some(block) = canonical_record_block(&record) {
self.record_journal_applied(block, applied.clone());
}
batch_report.applied.push(applied);
}
}
if self.root_gate_runnable(cache) {
self.touched_since_gate
.extend(touched_addrs.iter().copied());
if self.root_gate_due(canonical_batch_block) {
let accumulated = std::mem::take(&mut self.touched_since_gate);
self.run_root_gate(
cache,
canonical_batch_block,
&accumulated,
&mut batch_report.resyncs,
&mut reports_to_dispatch,
);
self.last_gate_block = canonical_batch_block;
}
} else {
self.touched_since_gate.clear();
}
batch_report.reports = reports_to_dispatch;
Ok(batch_report)
}
fn root_gate_runnable(&self, cache: &EvmCache) -> bool {
if matches!(self.root_gate_cadence, RootGateCadence::Disabled) {
return false;
}
let has_gated_targets = self
.tracking
.values()
.any(|policy| !matches!(policy, TrackingPolicy::Slots { .. }));
has_gated_targets && cache.account_proof_fetcher().is_some()
}
fn root_gate_due(&self, canonical_block: Option<u64>) -> bool {
let Some(block) = canonical_block else {
return false;
};
match self.root_gate_cadence {
RootGateCadence::Disabled => false,
RootGateCadence::EveryNBlocks(n) => match self.last_gate_block {
None => true,
Some(last) => block >= last.saturating_add(n.get()),
},
}
}
fn run_root_gate(
&mut self,
cache: &EvmCache,
canonical_block: Option<u64>,
touched: &HashSet<Address>,
resyncs: &mut Vec<ResyncRequest>,
reports: &mut Vec<Arc<ReactiveReport<N>>>,
) {
if self.tracking.is_empty() {
return;
}
let Some(block) = canonical_block else {
return;
};
let Some(fetcher) = cache.account_proof_fetcher().cloned() else {
return;
};
let mut targets: Vec<(Address, bool)> = self
.tracking
.iter()
.filter_map(|(address, policy)| match policy {
TrackingPolicy::Slots { .. } => None,
TrackingPolicy::WholeAccount => Some((*address, true)),
TrackingPolicy::Scalars => Some((*address, false)),
})
.collect();
if targets.is_empty() {
return;
}
targets.sort_by_key(|(address, _)| *address);
let block_id = BlockId::number(block);
let mut probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
targets
.iter()
.map(|&(address, _)| (address, vec![]))
.collect(),
block_id,
)
.into_iter()
.collect();
for (address, whole_account) in targets {
let Some(Ok(proof)) = probes.remove(&address) else {
continue;
};
let baseline = self.tracked_roots.get(&address).cloned();
let Some(baseline) = baseline else {
self.adopt_root(address, block, &proof);
continue;
};
if block <= baseline.last_block {
continue;
}
if whole_account {
if proof.storage_hash == baseline.last_root {
continue;
}
if !touched.contains(&address) {
reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport {
address,
block,
_network: PhantomData,
})));
self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed);
resyncs.push(root_moved_account_resync(
address,
block,
AccountFieldMask {
balance: true,
nonce: true,
code: true,
},
));
}
self.adopt_root(address, block, &proof);
} else {
let balance_moved = proof.balance != baseline.balance;
let nonce_moved = proof.nonce != baseline.nonce;
let code_moved = proof.code_hash != baseline.code_hash;
if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) {
resyncs.push(root_moved_account_resync(
address,
block,
AccountFieldMask {
balance: balance_moved,
nonce: nonce_moved,
code: code_moved,
},
));
}
self.adopt_root(address, block, &proof);
}
}
}
fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) {
self.tracked_roots.insert(
address,
TrackedRoot {
last_root: proof.storage_hash,
last_block: block,
balance: proof.balance,
nonce: proof.nonce,
code_hash: proof.code_hash,
},
);
}
fn execute_handlers(
&self,
cache: &EvmCache,
record: &ReactiveInputRecord<N>,
input_ref: InputRef,
) -> Result<Vec<HandlerExecution>, ReactiveError> {
let mut executions = Vec::new();
let candidates: Vec<_> = match &record.input {
ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
ReactiveInput::BlockHeader(_)
| ReactiveInput::FullBlock(_)
| ReactiveInput::PendingTxHash(_)
| ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
};
for registered in candidates {
if !registered.matches(&record.input) {
continue;
}
let outcome = registered
.handler
.handle(&record.context, &record.input, cache)
.map_err(|source| ReactiveError::HandlerFailed {
handler_id: registered.id.clone(),
source,
})?;
if let Err(error) =
validate_effects(input_ref, &record.context, ®istered.id, &outcome.effects)
{
if matches!(error, ReactiveError::InvalidPendingEffect { .. }) {
self.metrics
.pending_contamination
.fetch_add(1, Ordering::Relaxed);
}
return Err(error);
}
executions.push(HandlerExecution::from_outcome(
registered.id.clone(),
input_ref,
outcome,
));
}
Ok(executions)
}
fn dispatch_reports(&self, reports: &[Arc<ReactiveReport<N>>]) {
for report in reports {
for hook in &self.hooks {
hook.on_report(report.clone());
}
}
}
fn recover_for_canonical_input(
&mut self,
cache: &mut EvmCache,
record: &ReactiveInputRecord<N>,
health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
) -> Option<ReorgReport<N>> {
let block = canonical_record_block(record)?;
let latest = self.journal.back()?.block.clone();
if self
.journal
.iter()
.any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
{
return None;
}
if block.number == latest.number.saturating_add(1) && block.parent_hash == Some(latest.hash)
{
return None;
}
if block.number > latest.number.saturating_add(1) {
self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed);
health_reports.extend(self.escalate_trust(block.number));
health_reports.push(Arc::new(ReactiveReport::MissedBlockRange(
MissedRangeReport {
from: latest.number + 1,
to: block.number - 1,
block: block.number,
_network: PhantomData,
},
)));
return None;
}
let dropped = if let Some(parent_hash) = block.parent_hash {
if let Some(parent_index) = self
.journal
.iter()
.rposition(|entry| entry.block.hash == parent_hash)
{
self.drain_journal_after(parent_index)
} else {
health_reports.extend(self.warn_under_recovery(block.number));
self.drain_journal_from_number(block.number)
}
} else {
health_reports.extend(self.warn_under_recovery(block.number));
self.drain_journal_from_number(block.number)
};
self.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
}
fn recover_for_reorged_input(
&mut self,
cache: &mut EvmCache,
record: &ReactiveInputRecord<N>,
health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
) -> Option<ReorgReport<N>> {
let (dropped_block, reason) = reorg_signal_block(record)?;
let dropped = if let Some(index) = self
.journal
.iter()
.position(|entry| entry.block.hash == dropped_block.hash)
{
self.drain_journal_from(index)
} else {
health_reports.extend(self.warn_under_recovery(dropped_block.number));
self.drain_journal_from_number(dropped_block.number)
};
if dropped.is_empty() {
let canceled_resyncs =
self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
if canceled_resyncs.is_empty() {
return None;
}
return Some(ReorgReport {
dropped: Some(dropped_block.clone()),
dropped_blocks: vec![dropped_block],
dropped_inputs: Vec::new(),
rollback_updates: Vec::new(),
rollback_diff: StateDiff::default(),
purge_updates: Vec::new(),
purge_diff: StateDiff::default(),
canceled_resyncs,
reason,
_network: PhantomData,
});
}
self.recover_dropped_journals(cache, dropped, reason)
}
fn warn_under_recovery(&mut self, reorg_number: u64) -> Option<Arc<ReactiveReport<N>>> {
let oldest_journaled = self.journal.front().map(|entry| entry.block.number);
tracing::warn!(
reorg_block = reorg_number,
oldest_journaled = ?oldest_journaled,
journal_depth = self.config.journal_depth,
"reactive reorg recovery is incomplete: the reorged block is no longer \
in the journal, so effects from blocks aged out of the journal are \
neither rolled back nor purged (the freshness/validation loop is the \
backstop). Increase ReactiveConfig::journal_depth to recover deeper \
reorgs precisely."
);
self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed);
self.escalate_trust(reorg_number)
}
fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) {
let entry = self.journal_entry_mut(block);
if !entry.inputs.contains(&input_ref) {
entry.inputs.push(input_ref);
}
self.trim_journal();
}
fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
self.journal_entry_mut(block).applied.push(applied);
self.trim_journal();
}
fn record_journal_resync(&mut self, report: &ResyncReport) {
if report.diff.is_empty() {
return;
}
let Some(block) = single_hash_pinned_resync_block(report) else {
return;
};
self.journal_entry_mut(&block).resynced.push(report.clone());
self.trim_journal();
}
fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal<N> {
if let Some(index) = self
.journal
.iter()
.position(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
{
return &mut self.journal[index];
}
self.journal.push_back(BlockJournal {
block: block.clone(),
inputs: Vec::new(),
applied: Vec::new(),
resynced: Vec::new(),
});
let index = self.journal.len() - 1;
&mut self.journal[index]
}
fn trim_journal(&mut self) {
if self.config.journal_depth == 0 {
self.journal.clear();
return;
}
while self.journal.len() > self.config.journal_depth {
self.journal.pop_front();
}
}
fn drain_journal_after(&mut self, index: usize) -> Vec<BlockJournal<N>> {
self.journal.drain((index + 1)..).collect()
}
fn drain_journal_from(&mut self, index: usize) -> Vec<BlockJournal<N>> {
self.journal.drain(index..).collect()
}
fn drain_journal_from_number(&mut self, number: u64) -> Vec<BlockJournal<N>> {
let Some(index) = self
.journal
.iter()
.position(|entry| entry.block.number >= number)
else {
return Vec::new();
};
self.drain_journal_from(index)
}
fn recover_dropped_journals(
&mut self,
cache: &mut EvmCache,
dropped: Vec<BlockJournal<N>>,
reason: ReorgReason,
) -> Option<ReorgReport<N>> {
if dropped.is_empty() {
return None;
}
let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block.clone()).collect();
let dropped_inputs: Vec<_> = dropped
.iter()
.flat_map(|entry| entry.inputs.iter().copied())
.collect();
let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks);
let purge_scopes = purge_scopes_for_dropped_journals(&dropped);
let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes);
let purge_updates: Vec<_> = purge_scopes
.iter()
.map(|(address, scope)| StateUpdate::purge(*address, scope.clone()))
.collect();
let rollback_diff = if rollback_updates.is_empty() {
StateDiff::default()
} else {
cache.apply_updates(&rollback_updates)
};
let purge_diff = if purge_updates.is_empty() {
StateDiff::default()
} else {
cache.apply_updates(&purge_updates)
};
Some(ReorgReport {
dropped: dropped_blocks.first().cloned(),
dropped_blocks,
dropped_inputs,
rollback_updates,
rollback_diff,
purge_updates,
purge_diff,
canceled_resyncs,
reason,
_network: PhantomData,
})
}
fn cancel_resyncs_for_dropped_blocks(
&mut self,
dropped_blocks: &[BlockRef],
) -> Vec<ResyncRequest> {
let mut canceled = Vec::new();
self.pending_resyncs.retain(|request| {
let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks);
if should_cancel {
canceled.push(request.clone());
}
!should_cancel
});
canceled
}
fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator<Item = &'a ResyncId>) {
let ids: HashSet<_> = ids.into_iter().cloned().collect();
self.pending_resyncs
.retain(|request| !ids.contains(&request.id));
}
}
fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet<Address>) {
into.extend(diff.slots.iter().map(|change| change.address));
into.extend(diff.accounts.iter().map(|change| change.address));
into.extend(diff.purged.iter().map(|purge| purge.address));
into.extend(diff.skipped.iter().map(|skipped| skipped.address));
into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address));
into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address));
into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address));
}
fn root_moved_account_resync(
address: Address,
block: u64,
fields: AccountFieldMask,
) -> ResyncRequest {
ResyncRequest {
id: ResyncId::new(format!("root-moved:{address:#x}:{block}")),
reason: ResyncReason::RootMoved,
block: ResyncBlock::Number(block),
targets: vec![ResyncTarget::Account { address, fields }],
priority: ResyncPriority::Normal,
}
}
fn canonical_record_block<N: Network>(record: &ReactiveInputRecord<N>) -> Option<&BlockRef> {
if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
return None;
}
if is_canonical_status(&record.context.chain_status) {
return context_block_ref(&record.context);
}
None
}
fn advance_block_for_canonical_record<N: Network>(
cache: &mut EvmCache,
record: &ReactiveInputRecord<N>,
) -> Option<Result<(), BlockContextError>> {
if !is_canonical_status(&record.context.chain_status) {
return None;
}
match &record.input {
ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)),
ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())),
_ => None,
}
}
fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> {
match &ctx.chain_status {
ChainStatus::Included { block, .. }
| ChainStatus::Safe { block }
| ChainStatus::Finalized { block } => Some(block),
ChainStatus::Reorged { dropped_from } => Some(dropped_from),
ChainStatus::Pending => ctx.block.as_ref(),
}
}
fn reorg_signal_block<N: Network>(
record: &ReactiveInputRecord<N>,
) -> Option<(BlockRef, ReorgReason)> {
if matches!(&record.input, ReactiveInput::Log(log) if log.removed) {
return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog));
}
if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status {
return Some((dropped_from.clone(), ReorgReason::ReorgedInput));
}
None
}
fn block_ref_from_record<N: Network>(record: &ReactiveInputRecord<N>) -> Option<BlockRef> {
context_block_ref(&record.context)
.cloned()
.or_else(|| match &record.input {
ReactiveInput::Log(log) => Some(BlockRef {
number: log.block_number?,
hash: log.block_hash?,
parent_hash: None,
timestamp: log.block_timestamp,
}),
ReactiveInput::BlockHeader(header) => Some(BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
}),
ReactiveInput::FullBlock(block) => {
let header = block.header();
Some(BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
})
}
ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None,
})
}
fn remove_canceled_resyncs_from_batch(
resyncs: &mut Vec<ResyncRequest>,
canceled: &[ResyncRequest],
) {
if canceled.is_empty() {
return;
}
let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect();
resyncs.retain(|request| !canceled_ids.contains(&request.id));
}
fn resync_target_address(target: &ResyncTarget) -> Address {
match target {
ResyncTarget::StorageSlot { address, .. }
| ResyncTarget::StorageSlots { address, .. }
| ResyncTarget::Account { address, .. } => *address,
}
}
fn resync_request_targets_dropped_block(
request: &ResyncRequest,
dropped_blocks: &[BlockRef],
) -> bool {
let ResyncBlock::Hash { number, hash, .. } = &request.block else {
return false;
};
dropped_blocks
.iter()
.any(|block| block.hash == *hash && block.number == *number)
}
fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option<BlockRef> {
let first = report.requested.first()?.block.clone();
if !report
.requested
.iter()
.all(|request| request.block == first)
{
return None;
}
let ResyncBlock::Hash { number, hash, .. } = first else {
return None;
};
Some(BlockRef {
number,
hash,
parent_hash: None,
timestamp: None,
})
}
fn purge_scopes_for_dropped_journals<N: Network>(
dropped: &[BlockJournal<N>],
) -> Vec<(Address, PurgeScope)> {
let mut scopes: Vec<(Address, PurgeScope)> = Vec::new();
for entry in dropped.iter().rev() {
for resynced in entry.resynced.iter().rev() {
merge_purge_scopes_for_diff(&mut scopes, &resynced.diff);
}
for applied in entry.applied.iter().rev() {
merge_purge_scopes_for_diff(&mut scopes, &applied.diff);
}
}
scopes
}
fn rollback_updates_for_dropped_journals<N: Network>(
dropped: &[BlockJournal<N>],
purge_scopes: &[(Address, PurgeScope)],
) -> Vec<StateUpdate> {
let purge_addresses: HashSet<_> = purge_scopes
.iter()
.map(|(address, _scope)| *address)
.collect();
let mut updates = Vec::new();
for entry in dropped.iter().rev() {
for resynced in entry.resynced.iter().rev() {
push_rollback_updates_for_diff(&mut updates, &resynced.diff, &purge_addresses);
}
for applied in entry.applied.iter().rev() {
push_rollback_updates_for_diff(&mut updates, &applied.diff, &purge_addresses);
}
}
updates
}
fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) {
for change in &diff.accounts {
merge_purge_scope(scopes, change.address, PurgeScope::Account);
}
for record in &diff.purged {
merge_purge_scope(scopes, record.address, record.scope.clone());
}
}
fn push_rollback_updates_for_diff(
updates: &mut Vec<StateUpdate>,
diff: &StateDiff,
purge_addresses: &HashSet<Address>,
) {
for change in diff.slots.iter().rev() {
if purge_addresses.contains(&change.address) {
continue;
}
updates.push(StateUpdate::slot(change.address, change.slot, change.old));
}
}
fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) {
if let Some((_existing_address, existing_scope)) = scopes
.iter_mut()
.find(|(existing_address, _scope)| *existing_address == address)
{
*existing_scope = merged_purge_scope(existing_scope.clone(), scope);
} else {
scopes.push((address, scope));
}
}
fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope {
match (left, right) {
(PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account,
(PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage,
(PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => {
for slot in right {
if !left.contains(&slot) {
left.push(slot);
}
}
PurgeScope::Slots(left)
}
}
}
#[derive(Clone, Debug)]
struct StorageFetchSlot {
address: Address,
slot: U256,
origins: Vec<StorageFetchOrigin>,
}
#[derive(Clone, Debug)]
struct StorageFetchOrigin {
request_id: ResyncId,
target: ResyncTarget,
}
#[derive(Clone, Debug)]
struct StorageFetchGroup {
block: ResyncBlock,
slots: Vec<StorageFetchSlot>,
seen: HashSet<(Address, U256)>,
}
#[derive(Clone, Debug)]
struct AccountResyncTarget {
request_id: ResyncId,
block: ResyncBlock,
address: Address,
fields: AccountFieldMask,
}
fn resolve_trace_resyncs(
cache: &EvmCache,
storage_groups: &mut Vec<StorageFetchGroup>,
account_targets: &mut Vec<AccountResyncTarget>,
state_updates: &mut Vec<StateUpdate>,
) {
let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else {
return;
};
let mut blocks = Vec::new();
let mut seen = HashSet::new();
for block in storage_groups
.iter()
.map(|group| group.block.clone())
.chain(account_targets.iter().map(|target| target.block.clone()))
{
if seen.insert(block.clone()) {
blocks.push(block);
}
}
let mut traces = HashMap::new();
for block in blocks {
match (fetcher)(resync_block_to_block_id(&block)) {
Ok(diff) => {
traces.insert(block, diff);
}
Err(error) => {
tracing::debug!(
block = ?block,
error = %error,
"block trace resync source failed; falling back to point resync"
);
}
}
}
for group in storage_groups.iter_mut() {
let Some(trace) = traces.get(&group.block) else {
continue;
};
group.slots.retain(|slot| {
if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) {
state_updates.push(StateUpdate::slot(slot.address, slot.slot, value));
return false;
}
cache
.cached_storage_value(slot.address, slot.slot)
.is_none()
});
group.seen = group
.slots
.iter()
.map(|slot| (slot.address, slot.slot))
.collect();
}
storage_groups.retain(|group| !group.slots.is_empty());
let mut unresolved_accounts = Vec::new();
for mut account in account_targets.drain(..) {
let Some(trace) = traces.get(&account.block) else {
unresolved_accounts.push(account);
continue;
};
let Some(trace_account) = trace
.accounts
.iter()
.find(|diff| diff.address == account.address)
else {
unresolved_accounts.push(account);
continue;
};
let mut patch = AccountPatch::default();
let mut unresolved = AccountFieldMask::default();
if account.fields.balance {
if let Some(balance) = trace_account.balance {
patch = patch.balance(balance);
} else {
unresolved.balance = true;
}
}
if account.fields.nonce {
if let Some(nonce) = trace_account.nonce {
patch = patch.nonce(nonce);
} else {
unresolved.nonce = true;
}
}
if account.fields.code {
if let Some(code) = &trace_account.code {
patch = patch.code(code.clone());
} else {
unresolved.code = true;
}
}
if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() {
state_updates.push(StateUpdate::account_upsert(account.address, patch));
}
if !account_field_mask_empty(unresolved) {
account.fields = unresolved;
unresolved_accounts.push(account);
}
}
*account_targets = unresolved_accounts;
}
fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option<U256> {
trace
.accounts
.iter()
.find(|account| account.address == address)
.and_then(|account| {
account
.storage
.iter()
.find(|entry| entry.slot == slot)
.map(|entry| entry.value)
})
}
fn account_field_mask_empty(mask: AccountFieldMask) -> bool {
!mask.balance && !mask.nonce && !mask.code
}
fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport {
let mut failed = Vec::new();
let mut storage_groups: Vec<StorageFetchGroup> = Vec::new();
let mut account_targets: Vec<AccountResyncTarget> = Vec::new();
for request in requests {
for target in &request.targets {
match target {
ResyncTarget::StorageSlot { address, slot } => {
push_storage_resync_slot(
&mut storage_groups,
&request.id,
&request.block,
*address,
*slot,
);
}
ResyncTarget::StorageSlots { address, slots } => {
for slot in slots {
push_storage_resync_slot(
&mut storage_groups,
&request.id,
&request.block,
*address,
*slot,
);
}
}
ResyncTarget::Account { address, fields } => {
account_targets.push(AccountResyncTarget {
request_id: request.id.clone(),
block: request.block.clone(),
address: *address,
fields: *fields,
});
}
}
}
}
let mut state_updates = Vec::new();
resolve_trace_resyncs(
cache,
&mut storage_groups,
&mut account_targets,
&mut state_updates,
);
if !storage_groups.is_empty() {
if let Some(fetcher) = cache.storage_batch_fetcher().cloned() {
for group in storage_groups {
let block = group.block.clone();
let fetches: Vec<(Address, U256)> = group
.slots
.iter()
.map(|slot| (slot.address, slot.slot))
.collect();
let results = (fetcher)(fetches, resync_block_to_block_id(&block));
let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group
.slots
.iter()
.cloned()
.map(|slot| ((slot.address, slot.slot), slot))
.collect();
for (address, slot, fetched) in results {
let Some(requested_slot) = pending.remove(&(address, slot)) else {
continue;
};
match fetched {
Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)),
Err(error) => {
let message = error.to_string();
push_resync_failures(
&mut failed,
&block,
requested_slot.origins,
ResyncFailureKind::StorageFetchFailed,
message,
);
}
}
}
for requested_slot in group.slots {
if pending
.remove(&(requested_slot.address, requested_slot.slot))
.is_some()
{
push_resync_failures(
&mut failed,
&block,
requested_slot.origins,
ResyncFailureKind::StorageFetchOmitted,
"storage batch fetcher did not return a value for slot".to_string(),
);
}
}
}
} else {
for group in storage_groups {
let block = group.block.clone();
for slot in group.slots {
push_resync_failures(
&mut failed,
&block,
slot.origins,
ResyncFailureKind::MissingStorageFetcher,
"storage resync requires a storage batch fetcher".to_string(),
);
}
}
}
}
if !account_targets.is_empty() {
if let Some(fetcher) = cache.account_proof_fetcher().cloned() {
let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new();
for account in account_targets {
let block_id = resync_block_to_block_id(&account.block);
match groups
.iter_mut()
.find(|(group_block, _)| *group_block == block_id)
{
Some((_, group)) => group.push(account),
None => groups.push((block_id, vec![account])),
}
}
for (block_id, group) in groups {
let probes: HashMap<Address, StorageFetchResult<AccountProof>> = (fetcher)(
group
.iter()
.map(|account| (account.address, vec![]))
.collect(),
block_id,
)
.into_iter()
.collect();
for account in group {
match probes.get(&account.address).cloned() {
Some(Ok(proof)) => {
let mut patch = AccountPatch::default();
if account.fields.balance {
patch = patch.balance(proof.balance);
}
if account.fields.nonce {
patch = patch.nonce(proof.nonce);
}
state_updates.push(StateUpdate::account_upsert(account.address, patch));
}
Some(Err(error)) => {
failed.push(ResyncFailure {
request_id: account.request_id,
block: account.block,
target: ResyncTarget::Account {
address: account.address,
fields: account.fields,
},
kind: ResyncFailureKind::AccountFetchFailed,
message: error.to_string(),
});
}
None => {
failed.push(ResyncFailure {
request_id: account.request_id,
block: account.block,
target: ResyncTarget::Account {
address: account.address,
fields: account.fields,
},
kind: ResyncFailureKind::AccountFetchOmitted,
message:
"account proof fetcher did not return a result for address"
.to_string(),
});
}
}
}
}
} else {
for account in account_targets {
failed.push(ResyncFailure {
request_id: account.request_id,
block: account.block,
target: ResyncTarget::Account {
address: account.address,
fields: account.fields,
},
kind: ResyncFailureKind::MissingAccountFetcher,
message: "account resync requires an account proof fetcher".to_string(),
});
}
}
}
let diff = if state_updates.is_empty() {
StateDiff::default()
} else {
cache.apply_updates(&state_updates)
};
ResyncReport {
requested: requests.to_vec(),
state_updates,
diff,
failed,
}
}
fn push_resync_failures(
failed: &mut Vec<ResyncFailure>,
block: &ResyncBlock,
origins: Vec<StorageFetchOrigin>,
kind: ResyncFailureKind,
message: String,
) {
for origin in origins {
failed.push(ResyncFailure {
request_id: origin.request_id,
block: block.clone(),
target: origin.target,
kind,
message: message.clone(),
});
}
}
fn push_storage_resync_slot(
groups: &mut Vec<StorageFetchGroup>,
request_id: &ResyncId,
block: &ResyncBlock,
address: Address,
slot: U256,
) {
let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) {
index
} else {
groups.push(StorageFetchGroup {
block: block.clone(),
slots: Vec::new(),
seen: HashSet::new(),
});
groups.len() - 1
};
let group = &mut groups[group_index];
let origin = StorageFetchOrigin {
request_id: request_id.clone(),
target: ResyncTarget::StorageSlot { address, slot },
};
if group.seen.insert((address, slot)) {
group.slots.push(StorageFetchSlot {
address,
slot,
origins: vec![origin],
});
} else if let Some(existing) = group
.slots
.iter_mut()
.find(|existing| existing.address == address && existing.slot == slot)
{
existing.origins.push(origin);
}
}
fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId {
match block {
ResyncBlock::Latest => BlockId::latest(),
ResyncBlock::Safe => BlockId::safe(),
ResyncBlock::Finalized => BlockId::finalized(),
ResyncBlock::Number(number) => BlockId::number(*number),
ResyncBlock::Hash {
number: _,
hash,
require_canonical,
} => BlockId::from((*hash, Some(*require_canonical))),
}
}
impl<N: Network> RegisteredHandler<N> {
fn matches(&self, input: &ReactiveInput<N>) -> bool {
self.interests
.iter()
.any(|interest| interest_matches(interest, input))
}
fn route_log(&self, log: &Log) -> Option<ReactiveLogRoute> {
self.interests.iter().find_map(|interest| match interest {
ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute {
handler_id: self.id.clone(),
route_key: interest.route_key(log),
}),
ReactiveInterest::Logs(_)
| ReactiveInterest::Blocks(_)
| ReactiveInterest::PendingTransactions(_) => None,
})
}
}
fn merge_log_subscription_filter(filters: &mut Vec<Filter>, next: &Filter) {
if let Some(existing) = filters
.iter_mut()
.find(|existing| existing.block_option == next.block_option)
{
merge_filter_set(&mut existing.address, &next.address);
for (existing_topic, next_topic) in existing.topics.iter_mut().zip(next.topics.iter()) {
merge_filter_set(existing_topic, next_topic);
}
} else {
filters.push(next.clone());
}
}
fn merge_filter_set<T: Clone + Eq + Hash>(target: &mut FilterSet<T>, source: &FilterSet<T>) {
if target.is_empty() {
return;
}
if source.is_empty() {
*target = FilterSet::default();
return;
}
for value in source.iter() {
target.insert(value.clone());
}
}
#[derive(Clone, Debug)]
struct HandlerExecution {
handler_id: HandlerId,
quality: StateEffectQuality,
tags: Vec<ReportTag>,
state_updates: Vec<StateUpdate>,
invalidations: Vec<InvalidationRequest>,
resyncs: Vec<ResyncRequest>,
speculative: Vec<SpeculativeRequest>,
hook_signals: Vec<HookSignal>,
}
impl HandlerExecution {
fn from_outcome(handler_id: HandlerId, input_ref: InputRef, outcome: HandlerOutcome) -> Self {
let mut state_updates = Vec::new();
let mut invalidations = Vec::new();
let mut resyncs = Vec::new();
let mut speculative = Vec::new();
let mut hook_signals = Vec::new();
for effect in outcome.effects {
match effect {
ReactiveEffect::StateUpdate(update) => state_updates.push(update),
ReactiveEffect::Invalidate(invalidation) => {
state_updates.push(StateUpdate::purge(
invalidation.address,
invalidation.scope.clone(),
));
invalidations.push(invalidation);
}
ReactiveEffect::Resync(request) => resyncs.push(request),
ReactiveEffect::Hook(signal) => hook_signals.push(signal),
ReactiveEffect::Speculative(mut request) => {
request.input_ref = input_ref;
speculative.push(request);
}
}
}
Self {
handler_id,
quality: outcome.quality,
tags: outcome.tags,
state_updates,
invalidations,
resyncs,
speculative,
hook_signals,
}
}
}
fn dedupe_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
let mut seen = HashSet::new();
let mut deduped = Vec::with_capacity(records.len());
for record in records {
if seen.insert(record.input_ref()) {
deduped.push(record);
}
}
deduped
}
fn sort_records<N: Network>(records: Vec<ReactiveInputRecord<N>>) -> Vec<ReactiveInputRecord<N>> {
let mut indexed: Vec<(usize, ReactiveInputRecord<N>)> =
records.into_iter().enumerate().collect();
indexed.sort_by_key(|(index, record)| record_sort_key(*index, record));
indexed.into_iter().map(|(_, record)| record).collect()
}
fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
if let ReactiveInput::Log(log) = &record.input
&& is_canonical_status(&record.context.chain_status)
&& !log.removed
{
return RecordSortKey {
class: 0,
block_number: log
.block_number
.or(record.context.block.as_ref().map(|block| block.number))
.unwrap_or(u64::MAX),
transaction_index: log
.transaction_index
.or(record.context.transaction_index)
.unwrap_or(u64::MAX),
log_index: log
.log_index
.or(record.context.log_index)
.unwrap_or(u64::MAX),
original_index: index,
};
}
RecordSortKey {
class: 1,
block_number: 0,
transaction_index: 0,
log_index: 0,
original_index: index,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct RecordSortKey {
class: u8,
block_number: u64,
transaction_index: u64,
log_index: u64,
original_index: usize,
}
fn interest_matches<N: Network>(interest: &ReactiveInterest<N>, input: &ReactiveInput<N>) -> bool {
match (interest, input) {
(ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log),
(
ReactiveInterest::Blocks(BlockInterest {
mode: BlockInterestMode::Header,
}),
ReactiveInput::BlockHeader(_),
) => true,
(
ReactiveInterest::Blocks(BlockInterest {
mode: BlockInterestMode::FullBlock,
}),
ReactiveInput::FullBlock(_),
) => true,
(ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => {
interest.matches_hash_only()
}
(ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => {
interest.matches_tx(tx)
}
_ => false,
}
}
fn validate_effects(
input_ref: InputRef,
ctx: &ReactiveContext,
handler_id: &HandlerId,
effects: &[ReactiveEffect],
) -> Result<(), ReactiveError> {
let pending = matches!(ctx.chain_status, ChainStatus::Pending)
|| matches!(input_ref, InputRef::PendingTx { .. });
if !pending {
return Ok(());
}
for effect in effects {
let effect_kind = match effect {
ReactiveEffect::StateUpdate(_) => Some("state_update"),
ReactiveEffect::Invalidate(_) => Some("invalidate"),
ReactiveEffect::Resync(_) => Some("resync"),
ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None,
};
if let Some(effect_kind) = effect_kind {
return Err(ReactiveError::InvalidPendingEffect {
input_ref: Box::new(input_ref),
handler_id: handler_id.clone(),
effect_kind,
});
}
}
Ok(())
}
fn detect_conflicts(
input_ref: InputRef,
executions: &[HandlerExecution],
) -> Result<(), ReactiveError> {
let mut writes: HashMap<EffectTarget, (AbsoluteValue, HandlerId)> = HashMap::new();
for execution in executions {
for update in &execution.state_updates {
for (target, value) in absolute_writes(update) {
if let Some((previous_value, previous_handler)) = writes.get(&target) {
if previous_value != &value {
return Err(ReactiveError::ConflictingEffects {
input_ref: Box::new(input_ref),
target: Box::new(target),
first: previous_handler.clone(),
second: execution.handler_id.clone(),
});
}
} else {
writes.insert(target, (value, execution.handler_id.clone()));
}
}
}
}
Ok(())
}
fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> {
match update {
StateUpdate::Slot {
address,
slot,
value,
} => vec![(
EffectTarget::StorageSlot {
address: *address,
slot: *slot,
},
AbsoluteValue::U256(*value),
)],
StateUpdate::SlotMasked {
address,
slot,
mask,
value,
} => vec![(
EffectTarget::MaskedStorageSlot {
address: *address,
slot: *slot,
mask: *mask,
},
AbsoluteValue::U256(*value),
)],
StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => {
account_patch_writes(*address, patch)
}
StateUpdate::SlotDelta { .. }
| StateUpdate::BalanceDelta { .. }
| StateUpdate::Purge { .. } => Vec::new(),
}
}
fn account_patch_writes(
address: Address,
patch: &AccountPatch,
) -> Vec<(EffectTarget, AbsoluteValue)> {
let mut writes = Vec::new();
if let Some(balance) = patch.balance {
writes.push((
EffectTarget::AccountBalance { address },
AbsoluteValue::U256(balance),
));
}
if let Some(nonce) = patch.nonce {
writes.push((
EffectTarget::AccountNonce { address },
AbsoluteValue::U64(nonce),
));
}
if let Some(code) = &patch.code {
writes.push((
EffectTarget::AccountCode { address },
AbsoluteValue::Bytes(code.clone()),
));
}
writes
}
fn input_ref<N: Network>(input: &ReactiveInput<N>, ctx: &ReactiveContext) -> InputRef {
match input {
ReactiveInput::Log(log) => InputRef::Log {
chain_id: ctx.chain_id,
block_hash: log
.block_hash
.or(ctx.block.as_ref().map(|block| block.hash))
.unwrap_or_default(),
transaction_hash: log.transaction_hash.unwrap_or_default(),
log_index: log.log_index.or(ctx.log_index).unwrap_or_default(),
},
ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx {
chain_id: ctx.chain_id,
hash: *hash,
},
ReactiveInput::PendingTx(tx) => InputRef::PendingTx {
chain_id: ctx.chain_id,
hash: tx.tx_hash(),
},
ReactiveInput::BlockHeader(header) => InputRef::Block {
chain_id: ctx.chain_id,
hash: header.hash(),
number: header.number(),
},
ReactiveInput::FullBlock(block) => {
let header = block.header();
InputRef::Block {
chain_id: ctx.chain_id,
hash: header.hash(),
number: header.number(),
}
}
}
}
fn is_canonical_status(status: &ChainStatus) -> bool {
matches!(
status,
ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }
)
}
pub struct EventDecoderHandler {
id: HandlerId,
decoder: Arc<dyn EventDecoder>,
interest: LogInterest,
}
impl EventDecoderHandler {
pub fn new(id: HandlerId, decoder: Arc<dyn EventDecoder>, interest: LogInterest) -> Self {
Self {
id,
decoder,
interest,
}
}
}
impl<N: Network> ReactiveHandler<N> for EventDecoderHandler {
fn id(&self) -> HandlerId {
self.id.clone()
}
fn interests(&self) -> Vec<ReactiveInterest<N>> {
vec![ReactiveInterest::Logs(self.interest.clone())]
}
fn handle(
&self,
_ctx: &ReactiveContext,
input: &ReactiveInput<N>,
state: &dyn StateView,
) -> Result<HandlerOutcome, HandlerError> {
let ReactiveInput::Log(log) = input else {
return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect));
};
Ok(HandlerOutcome {
effects: self
.decoder
.decode(&log.inner, state)
.into_iter()
.map(ReactiveEffect::StateUpdate)
.collect(),
quality: StateEffectQuality::ExactFromInput,
tags: Vec::new(),
})
}
}
pub trait EventSubscriber<N: Network = Ethereum>: Send {
fn register_interests(
&mut self,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError>;
fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
}
pub type SubscriberNextBatch<'a, N> = Pin<
Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
>;
pub type SubscriberNextScopedBatch<'a, N> = Pin<
Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum SubscriberMode {
#[default]
Auto,
PubSub,
Polling,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscriberConfig {
pub hydrate_pending_transactions: bool,
pub max_batch_size: usize,
pub max_log_addresses_per_subscription: usize,
pub reconnect: SubscriberReconnectConfig,
}
impl Default for SubscriberConfig {
fn default() -> Self {
Self {
hydrate_pending_transactions: false,
max_batch_size: 1024,
max_log_addresses_per_subscription: 1024,
reconnect: SubscriberReconnectConfig::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscriberReconnectConfig {
pub enabled: bool,
pub initial_delay: Duration,
pub retry_delay: Duration,
pub max_delay: Duration,
pub max_attempts: Option<usize>,
pub dedupe_window: usize,
}
impl Default for SubscriberReconnectConfig {
fn default() -> Self {
Self {
enabled: true,
initial_delay: Duration::ZERO,
retry_delay: Duration::from_millis(250),
max_delay: Duration::from_secs(30),
max_attempts: Some(3),
dedupe_window: 4096,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SubscriberBackfill {
from_block: u64,
to_block: Option<u64>,
}
impl SubscriberBackfill {
pub fn range(from_block: u64, to_block: u64) -> Self {
Self {
from_block,
to_block: Some(to_block),
}
}
pub fn from_block(from_block: u64) -> Self {
Self {
from_block,
to_block: None,
}
}
pub fn start_block(&self) -> u64 {
self.from_block
}
pub fn end_block(&self) -> Option<u64> {
self.to_block
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SubscriberOwnerEpoch {
owner: HandlerId,
sequence: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriberInputScope {
Canonical {
owners: Vec<SubscriberOwnerEpoch>,
},
OwnerOnly {
owners: Vec<SubscriberOwnerEpoch>,
},
}
impl SubscriberInputScope {
pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
match self {
Self::Canonical { owners } | Self::OwnerOnly { owners } => owners,
}
}
pub const fn is_canonical(&self) -> bool {
matches!(self, Self::Canonical { .. })
}
}
#[derive(Clone, Debug)]
pub struct SubscriberInputRecord<N: Network = Ethereum> {
record: ReactiveInputRecord<N>,
scope: SubscriberInputScope,
}
impl<N: Network> SubscriberInputRecord<N> {
pub const fn record(&self) -> &ReactiveInputRecord<N> {
&self.record
}
pub const fn scope(&self) -> &SubscriberInputScope {
&self.scope
}
pub fn into_record(self) -> ReactiveInputRecord<N> {
self.record
}
}
impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
type Target = ReactiveInputRecord<N>;
fn deref(&self) -> &Self::Target {
&self.record
}
}
#[derive(Clone, Debug)]
pub struct SubscriberInputBatch<N: Network = Ethereum> {
records: Vec<SubscriberInputRecord<N>>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
Control(C),
Batch(Option<SubscriberInputBatch<N>>),
}
impl<N: Network> SubscriberInputBatch<N> {
pub fn records(&self) -> &[SubscriberInputRecord<N>] {
&self.records
}
pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
self.records
}
fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
ReactiveInputBatch::new(
self.records
.into_iter()
.map(SubscriberInputRecord::into_record)
.collect(),
)
}
}
impl SubscriberOwnerEpoch {
pub const fn owner(&self) -> &HandlerId {
&self.owner
}
pub const fn sequence(&self) -> u64 {
self.sequence
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriberOwnerStart {
Live,
PostBlock(BlockRef),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubscriberOwnerState {
Staged,
Active,
Removing,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscriberOwnerProgress {
owner: SubscriberOwnerEpoch,
through: BlockRef,
}
impl SubscriberOwnerProgress {
pub const fn owner(&self) -> &SubscriberOwnerEpoch {
&self.owner
}
pub const fn through(&self) -> &BlockRef {
&self.through
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SubscriberOwnerError {
#[error(transparent)]
Subscriber(#[from] SubscriberError),
#[error("subscriber interest owner `{0}` is already registered")]
AlreadyRegistered(HandlerId),
#[error("post-block subscriber baseline {0} has no following block")]
PostBlockOverflow(u64),
#[error("subscriber owner epoch sequence exhausted")]
EpochExhausted,
#[error("subscriber owner epoch is not staged")]
NotStaged,
#[error("subscriber owner was staged live-only and has no catch-up baseline")]
MissingBaseline,
#[error("post-block subscriber owners support log interests only")]
UnsupportedPostBlockInterest,
#[error("subscriber reconcile target block {0} was not found")]
BlockUnavailable(u64),
#[error(
"subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
)]
BlockMismatch {
expected_number: u64,
expected_hash: B256,
actual_number: u64,
actual_hash: B256,
},
#[error("subscriber reconcile target block {target} precedes current owner position {current}")]
ProgressRegression {
current: u64,
target: u64,
},
#[error(
"subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
)]
ProgressConflict {
number: u64,
current_hash: B256,
target_hash: B256,
},
#[error("subscriber reconcile returned an invalid catch-up log: {0}")]
InvalidBackfillLog(&'static str),
}
pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
fn add_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError>;
fn add_interest_owner_with_backfill(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: SubscriberBackfill,
) -> Result<(), SubscriberError>;
fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>>;
fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]>;
}
pub struct ReactiveEngine<S, N: Network = Ethereum> {
runtime: ReactiveRuntime<N>,
subscriber: S,
}
impl<S, N> ReactiveEngine<S, N>
where
N: Network,
S: EventSubscriber<N>,
{
pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
Self {
runtime,
subscriber,
}
}
pub fn into_parts(self) -> (ReactiveRuntime<N>, S) {
(self.runtime, self.subscriber)
}
pub fn runtime(&self) -> &ReactiveRuntime<N> {
&self.runtime
}
pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime<N> {
&mut self.runtime
}
pub fn subscriber(&self) -> &S {
&self.subscriber
}
pub fn subscriber_mut(&mut self) -> &mut S {
&mut self.subscriber
}
pub fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
self.subscriber.next_batch()
}
pub fn ingest_batch(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
self.runtime.ingest_batch(cache, batch)
}
pub fn ingest_batch_with_resync(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
self.runtime.ingest_batch_with_resync(cache, batch)
}
pub async fn next_ingest(
&mut self,
cache: &mut EvmCache,
) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
let Some(batch) = self.subscriber.next_batch().await? else {
return Ok(None);
};
Ok(Some(self.runtime.ingest_batch(cache, batch)?))
}
pub async fn next_ingest_with_resync(
&mut self,
cache: &mut EvmCache,
) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
let Some(batch) = self.subscriber.next_batch().await? else {
return Ok(None);
};
Ok(Some(self.runtime.ingest_batch_with_resync(cache, batch)?))
}
}
impl<S, N> ReactiveEngine<S, N>
where
N: Network,
S: InterestOwnerSubscriber<N>,
{
pub fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), ReactiveEngineRegisterError> {
let backfill = self
.runtime
.last_canonical_block()
.map(|block| SubscriberBackfill::from_block(block.number));
self.register_handler_inner(handler, backfill)
}
pub fn register_handler_with_backfill(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
backfill: SubscriberBackfill,
) -> Result<(), ReactiveEngineRegisterError> {
self.register_handler_inner(handler, Some(backfill))
}
pub fn register_handler_live_only(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), ReactiveEngineRegisterError> {
self.register_handler_inner(handler, None)
}
fn register_handler_inner(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
backfill: Option<SubscriberBackfill>,
) -> Result<(), ReactiveEngineRegisterError> {
let id = handler.id();
self.runtime.register_handler(handler)?;
let interests = self
.runtime
.handler_interests(&id)
.expect("handler was just registered")
.to_vec();
let subscribed = match backfill {
Some(backfill) => {
self.subscriber
.add_interest_owner_with_backfill(id.clone(), &interests, backfill)
}
None => self.subscriber.add_interest_owner(id.clone(), &interests),
};
if let Err(error) = subscribed {
self.runtime.unregister_handler(&id);
return Err(error.into());
}
Ok(())
}
pub fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
for id in self.runtime.handler_ids() {
let interests = self
.runtime
.handler_interests(&id)
.map(<[ReactiveInterest<N>]>::to_vec)
.unwrap_or_default();
self.subscriber.add_interest_owner(id, &interests)?;
}
Ok(())
}
pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
self.subscriber.remove_interest_owner(id);
self.runtime.unregister_handler(id)
}
}
pub struct AlloySubscriber<P, N: Network = Ethereum> {
provider: P,
mode: SubscriberMode,
config: SubscriberConfig,
base_interests: Vec<ReactiveInterest<N>>,
owned_interests: Vec<OwnedSubscriberInterests<N>>,
next_owner_epoch: u64,
interests: Vec<ReactiveInterest<N>>,
log_source_ids: HashMap<Filter, usize>,
next_log_source_id: usize,
pending_backfills: VecDeque<QueuedSubscriberBackfill>,
sources_dirty: bool,
stream_revision: u64,
state: AlloySubscriberState<N>,
pending_records: VecDeque<SubscriberInputRecord<N>>,
pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
last_seen_log_blocks: HashMap<usize, u64>,
recent_input_refs: VecDeque<InputRef>,
recent_input_ref_set: HashSet<InputRef>,
recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
_network: PhantomData<N>,
}
struct OwnedSubscriberInterests<N: Network = Ethereum> {
owner: HandlerId,
interests: Vec<ReactiveInterest<N>>,
epoch: Option<SubscriberOwnerEpoch>,
state: SubscriberOwnerState,
baseline: Option<BlockRef>,
progress: Option<SubscriberOwnerProgress>,
progress_stream_revision: Option<u64>,
}
#[derive(Clone)]
struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
epoch: SubscriberOwnerEpoch,
interests: Vec<ReactiveInterest<N>>,
retained: BlockRef,
from_block: u64,
}
struct SubscriberOwnerCatchup {
logs: Vec<Log>,
certified: BlockRef,
}
struct SubscriberOwnerReconcileFilter {
filter: Filter,
from_block: u64,
}
struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
}
const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
struct QueuedSubscriberBackfill {
owner: HandlerId,
epoch: Option<SubscriberOwnerEpoch>,
filter: Filter,
backfill: SubscriberBackfill,
}
#[cfg(feature = "reactive-ws")]
fn ensure_ring_crypto_provider() {
use std::sync::Once;
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
impl<P, N: Network> AlloySubscriber<P, N> {
pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self {
#[cfg(feature = "reactive-ws")]
ensure_ring_crypto_provider();
Self {
provider,
mode,
config,
base_interests: Vec::new(),
owned_interests: Vec::new(),
next_owner_epoch: 0,
interests: Vec::new(),
log_source_ids: HashMap::new(),
next_log_source_id: 0,
pending_backfills: VecDeque::new(),
sources_dirty: true,
stream_revision: 0,
state: AlloySubscriberState::Uninitialized,
pending_records: VecDeque::new(),
pending_reconcile_owner_records: VecDeque::new(),
last_seen_log_blocks: HashMap::new(),
recent_input_refs: VecDeque::new(),
recent_input_ref_set: HashSet::new(),
recent_owner_input_refs: HashMap::new(),
recent_owner_input_ref_sets: HashMap::new(),
_network: PhantomData,
}
}
pub fn provider(&self) -> &P {
&self.provider
}
pub fn mode(&self) -> SubscriberMode {
self.mode
}
pub fn config(&self) -> &SubscriberConfig {
&self.config
}
pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
&self.interests
}
pub fn stage_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
start: SubscriberOwnerStart,
) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
validate_subscriber_config(&self.config)?;
if matches!(&start, SubscriberOwnerStart::PostBlock(_))
&& interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
if self
.owned_interests
.iter()
.any(|entry| entry.owner == owner)
{
return Err(SubscriberOwnerError::AlreadyRegistered(owner));
}
let mut next_owned = self.clone_owned_interests();
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Staged,
baseline: None,
progress: None,
progress_stream_revision: None,
});
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let baseline = match start {
SubscriberOwnerStart::Live => None,
SubscriberOwnerStart::PostBlock(block) => {
block
.number
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
Some(block)
}
};
let sequence = self
.next_owner_epoch
.checked_add(1)
.ok_or(SubscriberOwnerError::EpochExhausted)?;
let epoch = SubscriberOwnerEpoch {
owner: owner.clone(),
sequence,
};
self.next_owner_epoch = sequence;
let entry = next_owned
.last_mut()
.expect("staged owner was appended during preflight");
entry.epoch = Some(epoch.clone());
entry.baseline = baseline;
self.owned_interests = next_owned;
self.interests = next_registered;
self.sources_dirty = true;
Ok(epoch)
}
pub fn stage_interest_owner_replacement(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
start: SubscriberOwnerStart,
) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
validate_subscriber_config(&self.config)?;
if matches!(&start, SubscriberOwnerStart::PostBlock(_))
&& interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
let active_count = self
.owned_interests
.iter()
.filter(|entry| entry.owner == owner && entry.state == SubscriberOwnerState::Active)
.count();
if active_count != 1
|| self
.owned_interests
.iter()
.any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
{
return Err(SubscriberOwnerError::AlreadyRegistered(owner));
}
let mut next_owned = self.clone_owned_interests();
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Staged,
baseline: None,
progress: None,
progress_stream_revision: None,
});
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let baseline = match start {
SubscriberOwnerStart::Live => None,
SubscriberOwnerStart::PostBlock(block) => {
block
.number
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
Some(block)
}
};
let sequence = self
.next_owner_epoch
.checked_add(1)
.ok_or(SubscriberOwnerError::EpochExhausted)?;
let epoch = SubscriberOwnerEpoch {
owner: owner.clone(),
sequence,
};
self.next_owner_epoch = sequence;
let entry = next_owned
.last_mut()
.expect("staged replacement owner was appended during preflight");
entry.epoch = Some(epoch.clone());
entry.baseline = baseline;
self.owned_interests = next_owned;
self.interests = next_registered;
self.sources_dirty = true;
Ok(epoch)
}
pub fn interest_owner_state(
&self,
epoch: &SubscriberOwnerEpoch,
) -> Option<SubscriberOwnerState> {
self.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.map(|entry| entry.state)
}
pub fn interest_owner_progress(
&self,
epoch: &SubscriberOwnerEpoch,
) -> Option<&SubscriberOwnerProgress> {
self.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.and_then(|entry| entry.progress.as_ref())
}
pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let stream_revision = self.stream_revision;
let sources_dirty = self.sources_dirty;
let Some(entry) = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
if entry.state != SubscriberOwnerState::Staged
|| (entry.baseline.is_some()
&& (entry.progress.is_none()
|| entry.progress_stream_revision != Some(stream_revision)
|| sources_dirty))
{
return false;
}
entry.state = SubscriberOwnerState::Active;
true
}
pub fn commit_interest_owner_replacement(
&mut self,
active: &SubscriberOwnerEpoch,
replacement: &SubscriberOwnerEpoch,
) -> bool {
let Some(active_index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(active))
else {
return false;
};
let Some(replacement_index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(replacement))
else {
return false;
};
if active_index == replacement_index
|| active.owner() != replacement.owner()
|| self.owned_interests[active_index].state != SubscriberOwnerState::Active
|| self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
|| (self.owned_interests[replacement_index].baseline.is_some()
&& (self.owned_interests[replacement_index].progress.is_none()
|| self.owned_interests[replacement_index].progress_stream_revision
!= Some(self.stream_revision)
|| self.sources_dirty))
{
return false;
}
self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
self.owned_interests.remove(active_index);
self.purge_owner_epoch(active);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
true
}
pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let Some(entry) = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
if entry.state != SubscriberOwnerState::Active {
return false;
}
entry.state = SubscriberOwnerState::Removing;
true
}
pub fn finalize_interest_owner_removal(
&mut self,
epoch: &SubscriberOwnerEpoch,
) -> Option<Vec<ReactiveInterest<N>>> {
let index = self.owned_interests.iter().position(|entry| {
entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
})?;
let removed = self.owned_interests.remove(index).interests;
self.purge_owner_epoch(epoch);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
Some(removed)
}
pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let Some(index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
match self.owned_interests[index].state {
SubscriberOwnerState::Staged => {
self.owned_interests.remove(index);
self.purge_owner_epoch(epoch);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
true
}
SubscriberOwnerState::Removing => {
self.owned_interests[index].state = SubscriberOwnerState::Active;
true
}
SubscriberOwnerState::Active => false,
}
}
fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
self.pending_backfills
.retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
self.pending_records
.retain_mut(|pending| match &mut pending.scope {
SubscriberInputScope::Canonical { owners } => {
owners.retain(|owner| owner != epoch);
true
}
SubscriberInputScope::OwnerOnly { owners } => {
owners.retain(|owner| owner != epoch);
!owners.is_empty()
}
});
self.pending_reconcile_owner_records.retain_mut(|pending| {
pending.owners.retain(|owner| owner != epoch);
!pending.owners.is_empty()
});
self.recent_owner_input_refs.remove(epoch);
self.recent_owner_input_ref_sets.remove(epoch);
}
pub fn add_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError> {
self.set_interest_owner(owner, interests, None)
}
pub fn add_interest_owner_with_backfill(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: SubscriberBackfill,
) -> Result<(), SubscriberError> {
self.set_interest_owner(owner, interests, Some(backfill))
}
pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
let index = self
.owned_interests
.iter()
.position(|entry| &entry.owner == owner)?;
let removed = self.owned_interests.remove(index);
if let Some(epoch) = &removed.epoch {
self.purge_owner_epoch(epoch);
} else {
self.pending_backfills
.retain(|backfill| &backfill.owner != owner);
}
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
Some(removed.interests)
}
pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.owned_interests
.iter()
.find(|entry| &entry.owner == owner)
.map(|entry| entry.interests.as_slice())
}
fn set_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: Option<SubscriberBackfill>,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
let mut next_owned = self.clone_owned_interests();
let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
Some(entry) => {
entry.interests = interests.to_vec();
entry.state = SubscriberOwnerState::Active;
entry.baseline = None;
entry.progress = None;
entry.progress_stream_revision = None;
entry.epoch.take()
}
None => {
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Active,
baseline: None,
progress: None,
progress_stream_revision: None,
});
None
}
};
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let previous_filters: Vec<Filter> = self
.owner_interests(&owner)
.map(log_filters)
.unwrap_or_default();
let continuity_anchor: Option<u64> = previous_filters
.iter()
.filter_map(|filter| self.log_anchor(filter))
.min();
self.owned_interests = next_owned;
self.interests = next_registered;
if let Some(epoch) = replaced_epoch {
self.purge_owner_epoch(&epoch);
}
self.retire_unreferenced_filters();
self.sources_dirty = true;
self.pending_backfills
.retain(|queued| queued.owner != owner);
for filter in log_filters(interests) {
if let Some(backfill) = backfill {
self.pending_backfills.push_back(QueuedSubscriberBackfill {
owner: owner.clone(),
epoch: None,
filter: filter.clone(),
backfill,
});
}
let unchanged = previous_filters.contains(&filter);
let explicit_covers = backfill.is_some_and(|explicit| {
explicit.end_block().is_none()
&& continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
});
if let Some(anchor) = continuity_anchor
&& !unchanged
&& !explicit_covers
{
self.pending_backfills.push_back(QueuedSubscriberBackfill {
owner: owner.clone(),
epoch: None,
filter,
backfill: SubscriberBackfill::from_block(anchor),
});
}
}
Ok(())
}
fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
self.owned_interests
.iter()
.map(|entry| OwnedSubscriberInterests {
owner: entry.owner.clone(),
interests: entry.interests.clone(),
epoch: entry.epoch.clone(),
state: entry.state,
baseline: entry.baseline.clone(),
progress: entry.progress.clone(),
progress_stream_revision: entry.progress_stream_revision,
})
.collect()
}
fn rebuild_registered_interests(&mut self) {
self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
}
fn log_anchor(&self, filter: &Filter) -> Option<u64> {
if let Some(anchor) = self
.log_source_ids
.get(filter)
.and_then(|id| self.last_seen_log_blocks.get(id))
{
return Some(*anchor);
}
self.log_source_ids
.values()
.filter_map(|id| self.last_seen_log_blocks.get(id).copied())
.min()
}
#[allow(clippy::mutable_key_type)]
fn logical_log_filters(&self) -> Vec<Filter> {
let mut filters = log_filters(&self.base_interests);
for entry in &self.owned_interests {
filters.extend(log_filters(&entry.interests));
}
let mut seen = HashSet::new();
filters.retain(|filter| seen.insert(filter.clone()));
filters
}
fn log_stream_filters(&self) -> Vec<Filter> {
let mut merged = Vec::new();
for filter in self.logical_log_filters() {
merge_log_subscription_filter(&mut merged, &filter);
}
let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
let mut planned = Vec::new();
for filter in merged {
let mut addresses: Vec<_> = filter.address.iter().copied().collect();
if addresses.len() <= max_addresses {
planned.push(filter);
continue;
}
addresses.sort_unstable();
for chunk in addresses.chunks(max_addresses) {
let mut split = filter.clone();
split.address = FilterSet::default();
for address in chunk {
split.address.insert(*address);
}
planned.push(split);
}
}
planned
}
#[allow(clippy::mutable_key_type)]
fn retire_unreferenced_filters(&mut self) {
let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
if let AlloySubscriberState::Active(streams) = &self.state {
for entry in &streams.entries {
match &entry.source {
SubscriberStreamSource::PubSubLog { filter, .. }
| SubscriberStreamSource::PollingLog { filter } => {
live.insert(filter.clone());
}
SubscriberStreamSource::PubSubPendingHashes
| SubscriberStreamSource::PubSubBlockHeaders
| SubscriberStreamSource::PollingPendingHashes => {}
}
}
}
self.log_source_ids
.retain(|filter, _| live.contains(filter));
let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
self.last_seen_log_blocks
.retain(|id, _| live_ids.contains(id));
}
fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
if self.pending_records.is_empty() {
return None;
}
let len = self.config.max_batch_size.min(self.pending_records.len());
let records = self.pending_records.drain(..len).collect();
Some(SubscriberInputBatch { records })
}
fn reset_delivery_state(&mut self) {
self.pending_records.clear();
self.pending_reconcile_owner_records.clear();
self.last_seen_log_blocks.clear();
self.recent_input_refs.clear();
self.recent_input_ref_set.clear();
self.recent_owner_input_refs.clear();
self.recent_owner_input_ref_sets.clear();
self.pending_backfills.clear();
self.log_source_ids.clear();
self.next_log_source_id = 0;
self.sources_dirty = true;
}
fn bump_stream_revision(&mut self) {
self.stream_revision = self.stream_revision.saturating_add(1);
}
}
impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
fn add_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError> {
AlloySubscriber::add_interest_owner(self, owner, interests)
}
fn add_interest_owner_with_backfill(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: SubscriberBackfill,
) -> Result<(), SubscriberError> {
AlloySubscriber::add_interest_owner_with_backfill(self, owner, interests, backfill)
}
fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
AlloySubscriber::remove_interest_owner(self, owner)
}
fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
AlloySubscriber::owner_interests(self, owner)
}
}
enum AlloySubscriberState<N: Network> {
Uninitialized,
Active(SubscriberStreams<N>),
Empty,
}
struct SubscriberStreams<N: Network> {
entries: Vec<SubscriberStreamEntry<N>>,
next_index: usize,
}
struct SubscriberStreamEntry<N: Network> {
source: SubscriberStreamSource,
stream: BoxStream<'static, SubscriberEvent<N>>,
}
impl<N: Network> SubscriberStreams<N> {
fn new() -> Self {
Self {
entries: Vec::new(),
next_index: 0,
}
}
fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn push(
&mut self,
source: SubscriberStreamSource,
stream: BoxStream<'static, SubscriberEvent<N>>,
) {
self.entries.push(SubscriberStreamEntry { source, stream });
}
#[cfg(all(test, feature = "reactive-ws"))]
fn len(&self) -> usize {
self.entries.len()
}
fn contains_source(&self, source: &SubscriberStreamSource) -> bool {
self.entries
.iter()
.any(|entry| entry.source.same_key(source))
}
fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) {
self.entries
.retain(|entry| sources.iter().any(|source| entry.source.same_key(source)));
self.normalize_next_index();
}
fn normalize_next_index(&mut self) {
if self.entries.is_empty() {
self.next_index = 0;
} else if self.next_index >= self.entries.len() {
self.next_index %= self.entries.len();
}
}
async fn next(&mut self) -> Option<SubscriberEvent<N>> {
poll_fn(|cx| {
self.normalize_next_index();
if self.entries.is_empty() {
return std::task::Poll::Ready(None);
}
let mut index = self.next_index;
let mut checked = 0usize;
while checked < self.entries.len() {
if index >= self.entries.len() {
index = 0;
}
match self.entries[index].stream.as_mut().poll_next(cx) {
std::task::Poll::Ready(Some(event)) => {
if matches!(event, SubscriberEvent::StreamTerminated(_)) {
self.entries.remove(index);
self.next_index = if self.entries.is_empty() {
0
} else {
index % self.entries.len()
};
} else {
self.next_index = (index + 1) % self.entries.len();
}
return std::task::Poll::Ready(Some(event));
}
std::task::Poll::Ready(None) => {
self.entries.remove(index);
if self.entries.is_empty() {
self.next_index = 0;
return std::task::Poll::Ready(None);
}
}
std::task::Poll::Pending => {
checked += 1;
index += 1;
}
}
}
if self.entries.is_empty() {
std::task::Poll::Ready(None)
} else {
self.next_index = index % self.entries.len();
std::task::Poll::Pending
}
})
.await
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)]
enum SubscriberTransport {
PubSub,
Polling,
}
#[derive(Clone, Debug)]
enum SubscriberStreamSource {
PubSubLog { id: usize, filter: Filter },
PubSubPendingHashes,
PubSubBlockHeaders,
PollingLog { filter: Filter },
PollingPendingHashes,
}
impl SubscriberStreamSource {
fn label(&self) -> &'static str {
match self {
Self::PubSubLog { .. } => "pubsub log",
Self::PubSubPendingHashes => "pubsub pending transaction hash",
Self::PubSubBlockHeaders => "pubsub block header",
Self::PollingLog { .. } => "polling log",
Self::PollingPendingHashes => "polling pending transaction hash",
}
}
fn is_pubsub(&self) -> bool {
matches!(
self,
Self::PubSubLog { .. } | Self::PubSubPendingHashes | Self::PubSubBlockHeaders
)
}
fn same_key(&self, other: &Self) -> bool {
match (self, other) {
(Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
| (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
left == right
}
(Self::PubSubPendingHashes, Self::PubSubPendingHashes)
| (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
| (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
_ => false,
}
}
}
#[allow(dead_code)]
enum SubscriberEvent<N: Network> {
Log { source_id: usize, log: Log },
BackfilledLogs { source_id: usize, logs: Vec<Log> },
Logs(Vec<Log>),
BlockHeader(N::HeaderResponse),
PendingHash(B256),
PendingHashes(Vec<B256>),
StreamTerminated(SubscriberStreamSource),
}
impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
fn register_interests(
&mut self,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
validate_supported_interests(self.mode, &self.config, interests)?;
self.base_interests = interests.to_vec();
self.owned_interests.clear();
self.rebuild_registered_interests();
self.reset_delivery_state();
self.state = AlloySubscriberState::Uninitialized;
Ok(())
}
fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
Box::pin(async {
Ok(self
.next_scoped_batch()
.await?
.map(SubscriberInputBatch::into_reactive_batch))
})
}
}
impl<P, N> AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
pub async fn reconcile_interest_owner(
&mut self,
epoch: &SubscriberOwnerEpoch,
through: BlockRef,
) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
where
P: Clone,
{
self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
.await?
.pop()
.ok_or(SubscriberOwnerError::NotStaged)
}
pub async fn reconcile_interest_owners(
&mut self,
epochs: &[SubscriberOwnerEpoch],
through: BlockRef,
) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
where
P: Clone,
{
if epochs.is_empty() {
return Ok(Vec::new());
}
let mut seen = HashSet::new();
let mut plans = Vec::with_capacity(epochs.len());
for epoch in epochs {
if !seen.insert(epoch.clone()) {
continue;
}
let entry = self
.owned_interests
.iter()
.find(|entry| {
entry.epoch.as_ref() == Some(epoch)
&& entry.state == SubscriberOwnerState::Staged
})
.ok_or(SubscriberOwnerError::NotStaged)?;
let position = entry
.progress
.as_ref()
.map(|progress| &progress.through)
.or(entry.baseline.as_ref())
.ok_or(SubscriberOwnerError::MissingBaseline)?;
let baseline = position.number;
if through.number < baseline {
return Err(SubscriberOwnerError::ProgressRegression {
current: baseline,
target: through.number,
});
}
let from_block = baseline
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
if through.number == baseline && through.hash != position.hash {
return Err(SubscriberOwnerError::ProgressConflict {
number: baseline,
current_hash: position.hash,
target_hash: through.hash,
});
}
if through.number == from_block
&& through
.parent_hash
.is_some_and(|parent| parent != position.hash)
{
return Err(SubscriberOwnerError::ProgressConflict {
number: baseline,
current_hash: position.hash,
target_hash: through.parent_hash.expect("checked as present above"),
});
}
if entry
.interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
plans.push(SubscriberOwnerReconcilePlan {
epoch: epoch.clone(),
interests: entry.interests.clone(),
retained: position.clone(),
from_block,
});
}
self.ensure_streams().await?;
let provider = self.provider.clone();
let filters = merged_owner_reconcile_filters(&plans, through.number);
let retained = plans.iter().map(|plan| plan.retained.clone()).collect();
let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
let fetch = fetch_owner_catchup::<P, N>(provider, filters, retained, through);
let SubscriberOwnerCatchup { logs, certified } =
self.drive_reconcile_fetch(fetch, &target_epochs).await?;
self.pending_backfills.retain(|queued| {
queued
.epoch
.as_ref()
.is_none_or(|epoch| !target_epochs.contains(epoch))
});
let records = logs
.into_iter()
.map(|log| log_input_record(log, InputSource::Backfill))
.collect();
for record in dedupe_records(sort_records(records)) {
let block_number = match &record.input {
ReactiveInput::Log(log) => log
.block_number
.expect("bulk catch-up logs were validated before commit"),
_ => unreachable!("bulk owner catch-up contains log records only"),
};
let owners = plans
.iter()
.filter(|plan| block_number >= plan.from_block)
.filter(|plan| {
plan.interests
.iter()
.any(|interest| interest_matches(interest, &record.input))
})
.map(|plan| plan.epoch.clone())
.collect();
self.enqueue_owner_record_for_owners_unmerged(record, owners);
}
self.promote_reconcile_owner_records(&target_epochs);
self.seed_reconciled_filter_anchors(&plans, certified.number);
let stream_revision = self.stream_revision;
let mut progress = Vec::with_capacity(plans.len());
for plan in plans {
let item = SubscriberOwnerProgress {
owner: plan.epoch.clone(),
through: certified.clone(),
};
let entry = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
.expect("bulk reconcile holds exclusive access after epoch preflight");
entry.progress = Some(item.clone());
entry.progress_stream_revision = Some(stream_revision);
progress.push(item);
}
Ok(progress)
}
async fn drive_reconcile_fetch<T, F>(
&mut self,
fetch: F,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) -> Result<T, SubscriberOwnerError>
where
F: Future<Output = Result<T, SubscriberOwnerError>>,
{
if !matches!(&self.state, AlloySubscriberState::Active(_)) {
return fetch.await;
}
let mut fetch = Box::pin(fetch);
loop {
let event = {
let live = Box::pin(self.next_event());
match select(fetch, live).await {
Either::Left((result, pending_live)) => {
drop(pending_live);
return result;
}
Either::Right((event, pending_fetch)) => {
fetch = pending_fetch;
event
}
}
};
let event = event?.ok_or_else(|| {
SubscriberError::Provider(
"Alloy subscriber streams ended during owner reconcile".to_owned(),
)
})?;
self.buffer_reconcile_event_for_owners(&event, target_epochs);
self.enqueue_event_excluding_owners(event, target_epochs);
}
}
pub async fn next_scoped_batch_or<C, F>(
&mut self,
control: Pin<&mut F>,
) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
where
C: Send,
F: Future<Output = C> + Send,
{
let batch = self.next_scoped_batch();
match select(control, batch).await {
Either::Left((control, pending_batch)) => {
drop(pending_batch);
Ok(SubscriberDriverPoll::Control(control))
}
Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
}
}
pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
Box::pin(async {
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
self.drain_pending_backfills().await?;
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
self.ensure_streams().await?;
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
if self.interests.is_empty() {
return Ok(None);
}
loop {
let Some(event) = self.next_event().await? else {
return Ok(None);
};
self.enqueue_event(event);
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
}
})
}
async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
if !self.sources_dirty {
return Ok(());
}
if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
self.bump_stream_revision();
self.sources_dirty = false;
return Ok(());
}
let desired = self.stream_sources()?;
let missing: Vec<SubscriberStreamSource> = match &self.state {
AlloySubscriberState::Active(streams) => desired
.iter()
.filter(|source| !streams.contains_source(source))
.cloned()
.collect(),
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
};
let mut connected = Vec::new();
for source in missing {
let stream = self.connect_source_stream(source.clone()).await?;
if let Some(event) = self.backfill_reconnected_source(&source).await? {
self.enqueue_event(event);
}
connected.push((source, stream));
}
match &mut self.state {
AlloySubscriberState::Active(streams) => {
streams.retain_sources(&desired);
for (source, stream) in connected {
streams.push(source, stream);
}
if streams.is_empty() {
self.state = AlloySubscriberState::Empty;
}
}
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
let mut streams = SubscriberStreams::new();
for (source, stream) in connected {
streams.push(source, stream);
}
self.state = if streams.is_empty() {
AlloySubscriberState::Empty
} else {
AlloySubscriberState::Active(streams)
};
}
}
self.bump_stream_revision();
self.sources_dirty = false;
self.retire_unreferenced_filters();
Ok(())
}
async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
while let Some(queued) = self.pending_backfills.front() {
let epoch = queued.epoch.clone();
let owner_exists = match &epoch {
Some(epoch) => self.interest_owner_state(epoch).is_some(),
None => self.owner_interests(&queued.owner).is_some(),
};
if !owner_exists {
self.pending_backfills.pop_front();
continue;
}
let filter = queued.filter.clone();
let backfill = queued.backfill;
let to_block = match backfill.end_block() {
Some(to_block) => to_block,
None => self
.provider
.get_block_number()
.await
.map_err(provider_error)?,
};
if to_block < backfill.start_block() {
self.pending_backfills.pop_front();
continue;
}
let range = filter
.clone()
.from_block(backfill.start_block())
.to_block(to_block);
let logs = self
.provider
.get_logs(&range)
.await
.map_err(provider_error)?;
self.pending_backfills.pop_front();
let source_id = self.log_source_id(&filter);
self.enqueue_backfilled_logs(logs, Some(source_id), epoch.as_ref(), Some(backfill));
if epoch.is_none() {
let anchor = self
.last_seen_log_blocks
.entry(source_id)
.or_insert(to_block);
*anchor = (*anchor).max(to_block);
}
if !self.pending_records.is_empty() {
break;
}
}
Ok(())
}
fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
match resolve_subscriber_transport(self.mode)? {
SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()),
SubscriberTransport::Polling => Ok(self.polling_stream_sources()),
}
}
fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
let mut sources = Vec::new();
let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
for filter in self.log_stream_filters() {
let id = self.log_source_id(&filter);
if let Some(anchor) = inherited_anchor {
self.last_seen_log_blocks.entry(id).or_insert(anchor);
}
sources.push(SubscriberStreamSource::PubSubLog { id, filter });
}
if needs_pending_hash_stream(&self.interests) {
sources.push(SubscriberStreamSource::PubSubPendingHashes);
}
if needs_header_block_stream(&self.interests) {
sources.push(SubscriberStreamSource::PubSubBlockHeaders);
}
sources
}
fn polling_stream_sources(&self) -> Vec<SubscriberStreamSource> {
let mut sources = Vec::new();
for filter in self.log_stream_filters() {
sources.push(SubscriberStreamSource::PollingLog { filter });
}
if needs_pending_hash_stream(&self.interests) {
sources.push(SubscriberStreamSource::PollingPendingHashes);
}
sources
}
fn log_source_id(&mut self, filter: &Filter) -> usize {
if let Some(id) = self.log_source_ids.get(filter) {
return *id;
}
let id = self.next_log_source_id;
self.next_log_source_id = self.next_log_source_id.saturating_add(1);
self.log_source_ids.insert(filter.clone(), id);
id
}
async fn connect_source_stream(
&mut self,
source: SubscriberStreamSource,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
match source {
SubscriberStreamSource::PubSubLog { id, filter } => {
self.connect_pubsub_log_stream(id, filter).await
}
SubscriberStreamSource::PubSubPendingHashes => {
self.connect_pubsub_pending_hash_stream().await
}
SubscriberStreamSource::PubSubBlockHeaders => {
self.connect_pubsub_block_header_stream().await
}
SubscriberStreamSource::PollingLog { filter } => {
self.connect_polling_log_stream(filter).await
}
SubscriberStreamSource::PollingPendingHashes => {
self.connect_polling_pending_hash_stream().await
}
}
}
async fn connect_pubsub_log_stream(
&mut self,
id: usize,
filter: Filter,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
let source = SubscriberStreamSource::PubSubLog {
id,
filter: filter.clone(),
};
let stream = self
.provider
.subscribe_logs(&filter)
.channel_size(self.config.max_batch_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(move |log| SubscriberEvent::Log { source_id: id, log });
Ok(stream_with_termination(stream, source))
}
#[cfg(not(feature = "reactive-ws"))]
{
let _ = (id, filter);
Err(SubscriberError::Unsupported(
"AlloySubscriber pubsub mode requires the reactive-ws feature",
))
}
}
async fn connect_pubsub_pending_hash_stream(
&mut self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
let stream = self
.provider
.subscribe_pending_transactions()
.channel_size(self.config.max_batch_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(SubscriberEvent::PendingHash);
Ok(stream_with_termination(
stream,
SubscriberStreamSource::PubSubPendingHashes,
))
}
#[cfg(not(feature = "reactive-ws"))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber pubsub mode requires the reactive-ws feature",
))
}
}
async fn connect_pubsub_block_header_stream(
&mut self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
let stream = self
.provider
.subscribe_blocks()
.channel_size(self.config.max_batch_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(SubscriberEvent::BlockHeader);
Ok(stream_with_termination(
stream,
SubscriberStreamSource::PubSubBlockHeaders,
))
}
#[cfg(not(feature = "reactive-ws"))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber pubsub mode requires the reactive-ws feature",
))
}
}
async fn connect_polling_log_stream(
&mut self,
filter: Filter,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-polling")]
{
let source = SubscriberStreamSource::PollingLog {
filter: filter.clone(),
};
let stream = self
.provider
.watch_logs(&filter)
.await
.map_err(provider_error)?
.with_channel_size(self.config.max_batch_size.max(1))
.into_stream()
.map(SubscriberEvent::Logs);
Ok(stream_with_termination(stream, source))
}
#[cfg(not(feature = "reactive-polling"))]
{
let _ = filter;
Err(SubscriberError::Unsupported(
"AlloySubscriber polling mode requires the reactive-polling feature",
))
}
}
async fn connect_polling_pending_hash_stream(
&mut self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-polling")]
{
let stream = self
.provider
.watch_pending_transactions()
.await
.map_err(provider_error)?
.with_channel_size(self.config.max_batch_size.max(1))
.into_stream()
.map(SubscriberEvent::PendingHashes);
Ok(stream_with_termination(
stream,
SubscriberStreamSource::PollingPendingHashes,
))
}
#[cfg(not(feature = "reactive-polling"))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber polling mode requires the reactive-polling feature",
))
}
}
async fn next_event(&mut self) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
loop {
let event = match &mut self.state {
AlloySubscriberState::Active(streams) => streams.next().await,
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
return Ok(None);
}
};
let Some(event) = event else {
return Err(SubscriberError::Provider(
"Alloy subscriber streams terminated before the subscriber was stopped"
.to_owned(),
));
};
match event {
SubscriberEvent::StreamTerminated(source) => {
self.sources_dirty = true;
self.bump_stream_revision();
if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
self.sources_dirty = false;
return Ok(Some(backfill_event));
}
self.sources_dirty = false;
}
event => return Ok(Some(event)),
}
}
}
fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
self.enqueue_event_with_excluded_owners(event, None);
}
fn buffer_reconcile_event_for_owners(
&mut self,
event: &SubscriberEvent<N>,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) {
match event {
SubscriberEvent::Log { log, .. } => {
self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
}
SubscriberEvent::BackfilledLogs { logs, .. } => {
for log in logs {
self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
}
}
SubscriberEvent::Logs(logs) => {
for log in logs {
self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
}
}
SubscriberEvent::BlockHeader(_)
| SubscriberEvent::PendingHash(_)
| SubscriberEvent::PendingHashes(_)
| SubscriberEvent::StreamTerminated(_) => {}
}
}
fn buffer_reconcile_log_for_owners(
&mut self,
log: &Log,
source: InputSource,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) {
let record = log_input_record(log.clone(), source);
let owners = self
.staged_owners_for_record(&record)
.into_iter()
.filter(|owner| target_epochs.contains(owner))
.collect::<Vec<_>>();
if !owners.is_empty() {
self.pending_reconcile_owner_records
.push_back(BufferedSubscriberOwnerRecord { record, owners });
}
}
fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
let mut retained = VecDeque::new();
while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
let mut promoted = Vec::new();
buffered.owners.retain(|owner| {
if target_epochs.contains(owner) {
promoted.push(owner.clone());
false
} else {
true
}
});
if promoted.is_empty() {
retained.push_back(buffered);
continue;
}
let promoted_record = if buffered.owners.is_empty() {
buffered.record
} else {
let record = buffered.record.clone();
retained.push_back(buffered);
record
};
self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
}
self.pending_reconcile_owner_records = retained;
}
fn seed_reconciled_filter_anchors(
&mut self,
plans: &[SubscriberOwnerReconcilePlan<N>],
through: u64,
) {
for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
continue;
};
let anchor = self
.last_seen_log_blocks
.entry(source_id)
.or_insert(through);
*anchor = (*anchor).max(through);
}
}
fn enqueue_event_excluding_owners(
&mut self,
event: SubscriberEvent<N>,
excluded: &HashSet<SubscriberOwnerEpoch>,
) {
self.enqueue_event_with_excluded_owners(event, Some(excluded));
}
fn enqueue_event_with_excluded_owners(
&mut self,
event: SubscriberEvent<N>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
match event {
SubscriberEvent::Log { source_id, log } => {
if log_matches_any_interest(&log, &self.interests) {
let record = log_input_record(log, InputSource::Subscription);
self.note_log_block(source_id, &record);
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
SubscriberEvent::BackfilledLogs { source_id, logs } => {
self.enqueue_backfilled_logs_with_excluded_owners(
logs,
Some(source_id),
None,
None,
excluded,
);
}
SubscriberEvent::Logs(logs) => {
for log in logs {
if log_matches_any_interest(&log, &self.interests) {
self.enqueue_record_with_excluded_owners(
log_input_record(log, InputSource::Poll),
excluded,
);
}
}
}
SubscriberEvent::BlockHeader(header) => {
if needs_header_block_stream(&self.interests) {
let record = block_header_input_record::<N>(header);
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
SubscriberEvent::PendingHash(hash) => {
let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
self.enqueue_record_with_excluded_owners(record, excluded);
}
SubscriberEvent::PendingHashes(hashes) => {
for hash in hashes {
self.enqueue_record_with_excluded_owners(
pending_hash_input_record::<N>(hash, InputSource::Poll),
excluded,
);
}
}
SubscriberEvent::StreamTerminated(_) => {}
}
}
fn enqueue_backfilled_logs(
&mut self,
logs: Vec<Log>,
source_id: Option<usize>,
owner: Option<&SubscriberOwnerEpoch>,
range: Option<SubscriberBackfill>,
) {
self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
}
fn enqueue_backfilled_logs_with_excluded_owners(
&mut self,
logs: Vec<Log>,
source_id: Option<usize>,
owner: Option<&SubscriberOwnerEpoch>,
range: Option<SubscriberBackfill>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
for log in logs {
if range.is_some_and(|range| {
log.block_number.is_some_and(|block| {
block < range.start_block() || range.end_block().is_some_and(|end| block > end)
})
}) {
continue;
}
let matches = match owner {
Some(epoch) => self
.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
None => log_matches_any_interest(&log, &self.interests),
};
if matches {
let record = log_input_record(log, InputSource::Backfill);
if let Some(epoch) = owner {
self.enqueue_owner_record(record, epoch.clone());
} else {
if let Some(source_id) = source_id {
self.note_log_block(source_id, &record);
}
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
}
}
async fn reconnect_source_stream(
&mut self,
source: SubscriberStreamSource,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
if !source.is_pubsub() {
return Err(stream_terminated_error(&source));
}
if !self.config.reconnect.enabled {
return Err(SubscriberError::Provider(format!(
"Alloy subscriber {} stream terminated and reconnect is disabled",
source.label()
)));
}
let mut attempts = 0usize;
let mut delay = self.config.reconnect.initial_delay;
let mut retry_delay = self.config.reconnect.retry_delay;
loop {
attempts = attempts.saturating_add(1);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
match self.reconnect_source_once(source.clone()).await {
Ok(backfill_event) => return Ok(backfill_event),
Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => {
return Err(SubscriberError::Provider(format!(
"Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}",
source.label()
)));
}
Err(error) => {
tracing::warn!(
stream = source.label(),
attempts,
error = %error,
"Alloy subscriber reconnect attempt failed"
);
delay = retry_delay;
retry_delay =
next_reconnect_delay(retry_delay, self.config.reconnect.max_delay);
}
}
}
}
async fn reconnect_source_once(
&mut self,
source: SubscriberStreamSource,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
let stream = self.connect_source_stream(source.clone()).await?;
let backfill_event = self.backfill_reconnected_source(&source).await?;
match &mut self.state {
AlloySubscriberState::Active(streams) => streams.push(source, stream),
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
return Err(SubscriberError::Provider(
"Alloy subscriber state changed before reconnect completed".to_owned(),
));
}
}
Ok(backfill_event)
}
async fn backfill_reconnected_source(
&mut self,
source: &SubscriberStreamSource,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
let SubscriberStreamSource::PubSubLog { id, filter } = source else {
return Ok(None);
};
let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
return Ok(None);
};
let latest = self
.provider
.get_block_number()
.await
.map_err(provider_error)?;
if latest < from_block {
return Ok(None);
}
let logs = self
.provider
.get_logs(&filter.clone().from_block(from_block).to_block(latest))
.await
.map_err(provider_error)?;
Ok(Some(SubscriberEvent::BackfilledLogs {
source_id: *id,
logs,
}))
}
fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
if let Some(block) = record.context.block.as_ref() {
self.last_seen_log_blocks.insert(source_id, block.number);
}
}
fn enqueue_record_with_excluded_owners(
&mut self,
record: ReactiveInputRecord<N>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
let mut owners = self.staged_owners_for_record(&record);
if let Some(excluded) = excluded {
owners.retain(|owner| !excluded.contains(owner));
}
let canonical_duplicate = self.should_skip_recent_duplicate(&record);
let owners = self.filter_recent_owner_duplicates(&record, owners);
if canonical_duplicate {
if !owners.is_empty() {
self.pending_records.push_back(SubscriberInputRecord {
record,
scope: SubscriberInputScope::OwnerOnly { owners },
});
}
return;
}
self.remember_record(&record);
self.pending_records.push_back(SubscriberInputRecord {
record,
scope: SubscriberInputScope::Canonical { owners },
});
}
fn enqueue_owner_record(
&mut self,
record: ReactiveInputRecord<N>,
owner: SubscriberOwnerEpoch,
) {
self.enqueue_owner_record_for_owners(record, vec![owner]);
}
fn enqueue_owner_record_for_owners(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) {
self.enqueue_owner_record_for_owners_inner(record, owners, true);
}
fn enqueue_owner_record_for_owners_unmerged(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) {
self.enqueue_owner_record_for_owners_inner(record, owners, false);
}
fn enqueue_owner_record_for_owners_inner(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
merge_pending: bool,
) {
let owners = self.filter_recent_owner_duplicates(&record, owners);
if owners.is_empty() {
return;
}
if merge_pending
&& should_dedupe_record(&record)
&& self.config.reconnect.dedupe_window != 0
{
let input_ref = record.input_ref();
if let Some(pending) = self
.pending_records
.iter_mut()
.rev()
.find(|pending| pending.record.input_ref() == input_ref)
{
let pending_owners = match &mut pending.scope {
SubscriberInputScope::Canonical { owners }
| SubscriberInputScope::OwnerOnly { owners } => owners,
};
for owner in owners {
if !pending_owners.contains(&owner) {
pending_owners.push(owner);
}
}
return;
}
}
self.pending_records.push_back(SubscriberInputRecord {
record,
scope: SubscriberInputScope::OwnerOnly { owners },
});
}
fn staged_owners_for_record(
&self,
record: &ReactiveInputRecord<N>,
) -> Vec<SubscriberOwnerEpoch> {
self.owned_interests
.iter()
.filter(|entry| entry.state == SubscriberOwnerState::Staged)
.filter(|entry| {
entry
.interests
.iter()
.any(|interest| interest_matches(interest, &record.input))
})
.filter_map(|entry| entry.epoch.clone())
.collect()
}
fn filter_recent_owner_duplicates(
&mut self,
record: &ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) -> Vec<SubscriberOwnerEpoch> {
if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
return owners;
}
let input_ref = record.input_ref();
let window = self.config.reconnect.dedupe_window;
owners
.into_iter()
.filter(|owner| {
let seen = self
.recent_owner_input_ref_sets
.entry(owner.clone())
.or_default();
if !seen.insert(input_ref) {
return false;
}
let recent = self
.recent_owner_input_refs
.entry(owner.clone())
.or_default();
recent.push_back(input_ref);
while recent.len() > window {
if let Some(evicted) = recent.pop_front() {
seen.remove(&evicted);
}
}
true
})
.collect()
}
fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
if !should_dedupe_record(record) {
return false;
}
self.recent_input_ref_set.contains(&record.input_ref())
}
fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
return;
}
let input_ref = record.input_ref();
if !self.recent_input_ref_set.insert(input_ref) {
return;
}
self.recent_input_refs.push_back(input_ref);
while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
if let Some(evicted) = self.recent_input_refs.pop_front() {
self.recent_input_ref_set.remove(&evicted);
}
}
}
}
#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))]
fn stream_with_termination<N, S>(
stream: S,
source: SubscriberStreamSource,
) -> BoxStream<'static, SubscriberEvent<N>>
where
N: Network + 'static,
S: futures::Stream<Item = SubscriberEvent<N>> + Send + 'static,
{
stream
.chain(stream::once(async move {
SubscriberEvent::StreamTerminated(source)
}))
.boxed()
}
fn aggregate_interests<N: Network>(
base: &[ReactiveInterest<N>],
owned: &[OwnedSubscriberInterests<N>],
) -> Vec<ReactiveInterest<N>> {
base.iter()
.cloned()
.chain(
owned
.iter()
.flat_map(|entry| entry.interests.iter().cloned()),
)
.collect()
}
fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError {
SubscriberError::Provider(format!(
"Alloy subscriber {} stream terminated before the subscriber was stopped",
source.label()
))
}
fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool {
config
.max_attempts
.is_some_and(|max_attempts| attempts >= max_attempts)
}
fn next_reconnect_delay(current: Duration, max: Duration) -> Duration {
if current.is_zero() {
return current;
}
current.checked_mul(2).unwrap_or(max).min(max)
}
fn should_dedupe_record<N: Network>(record: &ReactiveInputRecord<N>) -> bool {
match &record.input {
ReactiveInput::Log(log) => {
is_canonical_status(&record.context.chain_status) && !log.removed
}
ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true,
ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false,
}
}
#[cfg(test)]
mod subscriber_helper_tests {
use super::*;
use alloy_provider::ProviderBuilder;
use alloy_transport::mock::Asserter;
fn rpc_log(removed: bool) -> Log {
Log {
inner: alloy_primitives::Log::new_unchecked(
Address::repeat_byte(0x42),
vec![B256::repeat_byte(0x01)],
Bytes::new(),
),
block_hash: Some(B256::repeat_byte(0x02)),
block_number: Some(7),
block_timestamp: Some(1_700_000_000),
transaction_hash: Some(B256::repeat_byte(0x03)),
transaction_index: Some(4),
log_index: Some(5),
removed,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn stream_with_termination_yields_terminal_source_marker() {
let mut stream = stream_with_termination::<Ethereum, _>(
stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
0xaa,
))]),
SubscriberStreamSource::PubSubPendingHashes,
);
assert!(matches!(
stream.next().await,
Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
));
assert!(matches!(
stream.next().await,
Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
));
assert!(stream.next().await.is_none());
}
#[test]
fn reconnect_delay_doubles_until_capped() {
assert_eq!(
next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
Duration::from_millis(500)
);
assert_eq!(
next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
Duration::from_secs(1)
);
assert_eq!(
next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
Duration::ZERO
);
}
#[test]
fn canonical_logs_are_deduped_but_removed_logs_are_not() {
let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
assert!(should_dedupe_record(&included));
assert!(!should_dedupe_record(&removed));
}
#[test]
fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig::default(),
);
let owner = HandlerId::new("replace-owner");
let original = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
local_matcher: None,
route_key: None,
});
let replacement_interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let active = subscriber
.stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
.unwrap();
assert!(subscriber.activate_interest_owner(&active));
let replacement = subscriber
.stage_interest_owner_replacement(
owner,
&[replacement_interest],
SubscriberOwnerStart::Live,
)
.unwrap();
assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
assert_eq!(subscriber.interest_owner_state(&active), None);
assert_eq!(
subscriber.interest_owner_state(&replacement),
Some(SubscriberOwnerState::Active)
);
assert_eq!(subscriber.registered_interests().len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let owner_a = subscriber
.stage_interest_owner(
HandlerId::new("owner-a"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
)
.unwrap();
let owner_b = subscriber
.stage_interest_owner(
HandlerId::new("owner-b"),
&[interest],
SubscriberOwnerStart::Live,
)
.unwrap();
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
assert!(subscriber.abort_interest_owner(&owner_a));
let batch = subscriber
.next_scoped_batch()
.await
.unwrap()
.expect("shared canonical delivery remains queued");
assert_eq!(batch.records.len(), 1);
assert_eq!(
batch.records[0].scope,
SubscriberInputScope::Canonical {
owners: vec![owner_b]
}
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})],
SubscriberOwnerStart::Live,
)
.unwrap();
let log = rpc_log(false);
subscriber.enqueue_owner_record(
log_input_record(log.clone(), InputSource::Backfill),
epoch.clone(),
);
subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
let batch = subscriber
.next_scoped_batch()
.await
.unwrap()
.expect("owner backfill and canonical live delivery");
assert_eq!(batch.records.len(), 2);
assert_eq!(
batch.records[0].scope,
SubscriberInputScope::OwnerOnly {
owners: vec![epoch]
}
);
assert_eq!(
batch.records[1].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"owner replay dedupe must not suppress the global live record"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig {
max_batch_size: 2,
..SubscriberConfig::default()
},
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
)
.unwrap();
subscriber.sources_dirty = false;
let mut duplicate = rpc_log(false);
duplicate.transaction_hash = Some(B256::repeat_byte(1));
duplicate.log_index = Some(0);
let events = (0u8..10).map(|index| {
let mut log = rpc_log(false);
log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
log.log_index = Some(index as u64);
SubscriberEvent::Log { source_id: 0, log }
});
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PollingLog { filter },
stream::iter(events).boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let mut polls = 0usize;
let fetched_duplicate = duplicate.clone();
let fetch = poll_fn(move |cx| {
polls += 1;
if polls > 10 {
std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
} else {
cx.waker().wake_by_ref();
std::task::Poll::Pending
}
});
let target_epochs = HashSet::from([epoch.clone()]);
let fetched_duplicate = subscriber
.drive_reconcile_fetch(fetch, &target_epochs)
.await
.unwrap();
subscriber.enqueue_owner_record_for_owners_unmerged(
log_input_record(fetched_duplicate, InputSource::Backfill),
vec![epoch.clone()],
);
subscriber.promote_reconcile_owner_records(&target_epochs);
assert_eq!(subscriber.pending_records.len(), 20);
assert!(subscriber.pending_records.iter().take(10).all(|record| {
record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
}));
assert!(subscriber.pending_records.iter().skip(10).all(|record| {
record.scope
== SubscriberInputScope::OwnerOnly {
owners: vec![epoch.clone()],
}
}));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig::default(),
);
subscriber.sources_dirty = false;
let mut first_poll = true;
let fetch = poll_fn(move |cx| {
if first_poll {
first_poll = false;
cx.waker().wake_by_ref();
std::task::Poll::Pending
} else {
std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
}
});
let result = subscriber
.drive_reconcile_fetch(fetch, &HashSet::new())
.await
.expect("an empty live topology must not be mistaken for termination");
assert_eq!(result, "certified");
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
use alloy_rpc_types_eth::{Block, Header};
let asserter = Asserter::new();
let baseline = BlockRef {
number: 100,
hash: B256::repeat_byte(0x64),
parent_hash: Some(B256::repeat_byte(0x63)),
timestamp: Some(1_700_000_100),
};
let through = BlockRef {
number: 101,
hash: B256::repeat_byte(0x65),
parent_hash: Some(baseline.hash),
timestamp: Some(1_700_000_101),
};
let rpc_block = || -> Block {
Block::empty(Header {
hash: through.hash,
inner: alloy_consensus::Header {
number: through.number,
parent_hash: through.parent_hash.unwrap(),
timestamp: through.timestamp.unwrap(),
..Default::default()
},
total_difficulty: None,
size: None,
})
};
asserter.push_success(&Some(rpc_block()));
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Some(rpc_block()));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("reconnect-anchor"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(baseline),
)
.unwrap();
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let source = SubscriberStreamSource::PubSubLog {
id: subscriber.log_source_id(&filter),
filter: filter.clone(),
};
let mut streams = SubscriberStreams::new();
streams.push(source, stream::pending().boxed());
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
subscriber
.reconcile_interest_owner(&epoch, through.clone())
.await
.unwrap();
assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
assert!(asserter.read_q().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig::default(),
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(BlockRef {
number: 100,
hash: B256::repeat_byte(0x64),
parent_hash: None,
timestamp: None,
}),
)
.unwrap();
subscriber.sources_dirty = false;
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let event = SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
};
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PollingLog { filter },
stream::once(async move { event })
.chain(stream::pending())
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let targets = HashSet::from([epoch.clone()]);
{
let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
futures::pin_mut!(drive);
poll_fn(|cx| {
assert!(drive.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
}
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(
subscriber.pending_records[0].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"canonical delivery commits immediately at a cancellation-safe boundary"
);
assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
subscriber
.drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
.await
.unwrap();
subscriber.promote_reconcile_owner_records(&targets);
assert!(subscriber.pending_reconcile_owner_records.is_empty());
assert_eq!(subscriber.pending_records.len(), 2);
assert_eq!(
subscriber.pending_records[0].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"canonical delivery remains target-excluded"
);
assert_eq!(
subscriber.pending_records[1].scope,
SubscriberInputScope::OwnerOnly {
owners: vec![epoch]
},
"retry commit appends hidden owner delivery after historical catch-up"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
reconnect: SubscriberReconnectConfig {
initial_delay: Duration::from_secs(60),
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(BlockRef {
number: 7,
hash: B256::repeat_byte(0x07),
parent_hash: Some(B256::repeat_byte(0x06)),
timestamp: Some(1_700_000_007),
}),
)
.unwrap();
subscriber.sources_dirty = false;
subscriber.stream_revision = 1;
let entry = subscriber
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(&epoch))
.unwrap();
entry.progress = Some(SubscriberOwnerProgress {
owner: epoch.clone(),
through: entry.baseline.clone().unwrap(),
});
entry.progress_stream_revision = Some(1);
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let source = SubscriberStreamSource::PubSubLog {
id: subscriber.log_source_id(&filter),
filter,
};
let mut streams = SubscriberStreams::new();
streams.push(
source.clone(),
stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let prior_revision = subscriber.stream_revision;
let mut first_poll = true;
let control = poll_fn(move |cx| {
if first_poll {
first_poll = false;
cx.waker().wake_by_ref();
std::task::Poll::Pending
} else {
std::task::Poll::Ready("stop")
}
});
futures::pin_mut!(control);
let outcome = subscriber
.next_scoped_batch_or(control.as_mut())
.await
.unwrap();
assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
assert!(subscriber.sources_dirty);
assert!(subscriber.stream_revision > prior_revision);
assert!(
!subscriber.activate_interest_owner(&epoch),
"progress certified against the terminated stream revision is stale"
);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn pubsub_sources_assign_stable_log_ids_before_shared_streams() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.register_interests(&[
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
}),
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x02)),
local_matcher: None,
route_key: None,
}),
ReactiveInterest::PendingTransactions(PendingTxInterest::default()),
])
.expect("register base interests");
let sources = subscriber.stream_sources().expect("stream sources");
assert_eq!(sources.len(), 2);
assert!(matches!(
&sources[0],
SubscriberStreamSource::PubSubLog { id: 0, .. }
));
assert!(matches!(
sources[1],
SubscriberStreamSource::PubSubPendingHashes
));
let again = subscriber.stream_sources().expect("stream sources again");
assert!(again[0].same_key(&sources[0]));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn pubsub_stream_termination_attempts_reconnect_before_error() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
reconnect: SubscriberReconnectConfig {
initial_delay: Duration::ZERO,
retry_delay: Duration::ZERO,
max_delay: Duration::ZERO,
max_attempts: Some(1),
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
);
subscriber.interests = vec![ReactiveInterest::PendingTransactions(
PendingTxInterest::default(),
)];
let mut streams = SubscriberStreams::new();
let source = SubscriberStreamSource::PubSubPendingHashes;
streams.push(
source,
stream::once(async {
SubscriberEvent::<Ethereum>::StreamTerminated(
SubscriberStreamSource::PubSubPendingHashes,
)
})
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let result = subscriber.next_batch().await;
assert!(
matches!(result, Err(SubscriberError::Provider(ref message)) if message.contains("reconnect failed after 1 attempt")),
"terminated pubsub streams should attempt reconnect before surfacing failure: {result:?}"
);
}
#[test]
fn backfilled_logs_skip_recent_subscription_duplicates() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})];
let log = rpc_log(false);
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: log.clone(),
});
subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
source_id: 0,
logs: vec![log],
});
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
assert_eq!(
subscriber.pending_records[0].context.source,
InputSource::Subscription
);
}
#[test]
fn backfilled_logs_surface_with_backfill_source() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})];
subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
source_id: 0,
logs: vec![rpc_log(false)],
});
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(
subscriber.pending_records[0].context.source,
InputSource::Backfill
);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_removal_preserves_delivery_and_dedupe_state() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(
HandlerId::new("pool-a"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-a owner");
subscriber
.add_interest_owner(
HandlerId::new("pool-b"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x24))
.event_signature(B256::repeat_byte(0x02)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-b owner");
let sources = subscriber.stream_sources().expect("stream sources");
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
let mut streams = SubscriberStreams::new();
streams.push(
sources[0].clone(),
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.recent_input_refs.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
let removed = subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("pool-b should be removed");
assert_eq!(removed.len(), 1);
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.recent_input_refs.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-a"))
.is_some()
);
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-b"))
.is_none()
);
assert_eq!(subscriber.registered_interests().len(), 1);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_log_sources_fan_in_across_owners() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(
HandlerId::new("pool-a"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-a owner");
let initial_sources = subscriber.stream_sources().expect("initial sources");
assert_eq!(initial_sources.len(), 1);
let pool_a_source = initial_sources[0].clone();
assert!(matches!(
&pool_a_source,
SubscriberStreamSource::PubSubLog { id: 0, .. }
));
subscriber
.add_interest_owner(
HandlerId::new("pool-b"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-b owner");
let expanded_sources = subscriber.stream_sources().expect("expanded sources");
assert_eq!(
expanded_sources.len(),
1,
"compatible owner filters should share one provider subscription"
);
assert!(
!expanded_sources[0].same_key(&pool_a_source),
"the provider-facing superset changes while owner routing remains exact"
);
subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("pool-b should be removed");
let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
assert_eq!(trimmed_sources.len(), 1);
assert!(trimmed_sources[0].same_key(&pool_a_source));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn provider_log_fan_in_respects_address_ceiling() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
max_log_addresses_per_subscription: 2,
..SubscriberConfig::default()
},
);
for index in 0..5 {
subscriber
.add_interest_owner(
HandlerId::new(format!("pool-{index}")),
&[log_interest_for(index + 1)],
)
.expect("register pool owner");
}
let sources = subscriber.stream_sources().expect("stream sources");
assert_eq!(sources.len(), 3);
let mut address_counts: Vec<_> = sources
.iter()
.map(|source| match source {
SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
_ => panic!("expected log source"),
})
.collect();
address_counts.sort_unstable();
assert_eq!(address_counts, vec![1, 2, 2]);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
let asserter = Asserter::new();
asserter.push_success(&vec![rpc_log(false)]);
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool-a"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})],
SubscriberBackfill::range(1, 7),
)
.expect("register pool-a with backfill");
subscriber
.drain_pending_backfills()
.await
.expect("owner backfill should drain");
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
}
#[tokio::test(flavor = "multi_thread")]
async fn subscriber_streams_poll_ready_sources_round_robin() {
let first_hash = B256::repeat_byte(0x01);
let second_hash = B256::repeat_byte(0x02);
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PubSubPendingHashes,
stream::iter([
SubscriberEvent::<Ethereum>::PendingHash(first_hash),
SubscriberEvent::<Ethereum>::PendingHash(first_hash),
])
.boxed(),
);
streams.push(
SubscriberStreamSource::PubSubBlockHeaders,
stream::once(async move { SubscriberEvent::<Ethereum>::PendingHash(second_hash) })
.boxed(),
);
assert!(matches!(
streams.next().await,
Some(SubscriberEvent::PendingHash(hash)) if hash == first_hash
));
assert!(matches!(
streams.next().await,
Some(SubscriberEvent::PendingHash(hash)) if hash == second_hash
));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn owner_updates_ensure_streams_without_full_reset() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.register_interests(&[ReactiveInterest::PendingTransactions(
PendingTxInterest::default(),
)])
.expect("register base pending interest");
subscriber
.add_interest_owner(
HandlerId::new("headers"),
&[ReactiveInterest::Blocks(BlockInterest::default())],
)
.expect("register header owner");
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PubSubPendingHashes,
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
streams.push(
SubscriberStreamSource::PubSubBlockHeaders,
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
subscriber
.remove_interest_owner(&HandlerId::new("headers"))
.expect("header owner should be removed");
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams) if streams.len() == 2
));
subscriber
.ensure_streams()
.await
.expect("pure removal reconciliation should not touch provider");
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams)
if streams.len() == 1
&& streams.contains_source(&SubscriberStreamSource::PubSubPendingHashes)
&& !streams.contains_source(&SubscriberStreamSource::PubSubBlockHeaders)
));
subscriber
.add_interest_owner(
HandlerId::new("headers"),
&[ReactiveInterest::Blocks(BlockInterest::default())],
)
.expect("re-add header owner");
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams) if streams.len() == 1
));
}
#[cfg(feature = "reactive-ws")]
fn log_interest_matching_rpc_log() -> ReactiveInterest<Ethereum> {
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})
}
#[cfg(feature = "reactive-ws")]
fn log_interest_for(address: u8) -> ReactiveInterest<Ethereum> {
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(address)),
local_matcher: None,
route_key: None,
})
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn drain_backfill_retains_queue_entry_on_provider_error() {
let asserter = Asserter::new();
asserter.push_failure_msg("rate limited");
asserter.push_success(&vec![rpc_log(false)]);
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool"),
&[log_interest_matching_rpc_log()],
SubscriberBackfill::range(1, 7),
)
.expect("register owner with backfill");
assert_eq!(subscriber.pending_backfills.len(), 1);
let first = subscriber.drain_pending_backfills().await;
assert!(first.is_err(), "provider failure should surface");
assert_eq!(
subscriber.pending_backfills.len(),
1,
"failed fetch must leave the backfill queued for retry"
);
assert!(subscriber.pending_records.is_empty());
subscriber
.drain_pending_backfills()
.await
.expect("retry should succeed");
assert!(subscriber.pending_backfills.is_empty());
assert_eq!(subscriber.pending_records.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn drain_backfill_seeds_anchor_on_empty_window() {
let asserter = Asserter::new();
asserter.push_success(&Vec::<Log>::new());
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool"),
&[log_interest_matching_rpc_log()],
SubscriberBackfill::range(1, 42),
)
.expect("register owner with backfill");
subscriber
.drain_pending_backfills()
.await
.expect("empty backfill should drain");
assert!(subscriber.pending_records.is_empty());
let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
.pop()
.unwrap();
assert_eq!(
subscriber.log_anchor(&filter),
Some(42),
"empty window must still seed the anchor at its upper bound"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() {
let asserter = Asserter::new();
asserter.push_success(&100u64); asserter.push_success(&Vec::<Log>::new()); let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool"),
&[log_interest_matching_rpc_log()],
SubscriberBackfill::from_block(10),
)
.expect("register owner with open-ended backfill");
subscriber
.drain_pending_backfills()
.await
.expect("open-ended backfill should drain");
let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
.pop()
.unwrap();
assert_eq!(subscriber.log_anchor(&filter), Some(100));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn duplicate_filters_across_owners_map_to_single_source() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
.expect("register pool-a");
subscriber
.add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
.expect("register pool-b with identical filter");
assert_eq!(
subscriber.log_stream_filters().len(),
1,
"identical filters across owners must collapse to one"
);
let sources = subscriber.stream_sources().expect("stream sources");
assert_eq!(sources.len(), 1);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_removal_prunes_source_ids_and_anchors() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
.expect("register pool-a");
subscriber
.add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
.expect("register pool-b");
let _ = subscriber.stream_sources().expect("stream sources");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
let id_b = subscriber.log_source_id(&filter_b);
subscriber.last_seen_log_blocks.insert(id_a, 10);
subscriber.last_seen_log_blocks.insert(id_b, 20);
assert_eq!(
subscriber.log_source_ids.len(),
3,
"one provider fan-in id plus two explicitly seeded logical ids"
);
subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("remove pool-b");
assert_eq!(
subscriber.log_source_ids.len(),
1,
"pool-b's filter id should be retired"
);
assert!(subscriber.log_source_ids.contains_key(&filter_a));
assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
assert_eq!(
subscriber.last_seen_log_blocks.get(&id_b),
None,
"pool-b's anchor should be pruned"
);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
.expect("register amm with pool A");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
subscriber.last_seen_log_blocks.insert(id_a, 50);
subscriber
.add_interest_owner(
HandlerId::new("amm"),
&[log_interest_for(0xaa), log_interest_for(0xbb)],
)
.expect("grow amm to pools A+B");
assert_eq!(
subscriber.pending_backfills.len(),
1,
"the changed merged filter should queue exactly one continuity backfill"
);
let queued = &subscriber.pending_backfills[0];
assert_eq!(queued.owner, HandlerId::new("amm"));
assert_eq!(queued.backfill.start_block(), 50);
assert_eq!(
queued.backfill.end_block(),
None,
"continuity backfill runs open-ended to the current head"
);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn unchanged_owner_filter_does_not_queue_continuity_backfill() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
.expect("register amm");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
subscriber.last_seen_log_blocks.insert(id_a, 50);
subscriber
.add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
.expect("re-register identical interests");
assert!(
subscriber.pending_backfills.is_empty(),
"an unchanged filter shape must not queue continuity backfill"
);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn explicit_open_ended_backfill_below_anchor_suppresses_continuity() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
.expect("register amm");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
subscriber.last_seen_log_blocks.insert(id_a, 50);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("amm"),
&[log_interest_for(0xaa), log_interest_for(0xbb)],
SubscriberBackfill::from_block(10),
)
.expect("grow amm with explicit deep backfill");
assert_eq!(
subscriber.pending_backfills.len(),
1,
"only the explicit backfill should be queued; continuity is subsumed"
);
assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 10);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn ensure_streams_is_noop_when_not_dirty() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(
HandlerId::new("headers"),
&[ReactiveInterest::Blocks(BlockInterest::default())],
)
.expect("register header owner");
subscriber.state = AlloySubscriberState::Empty;
subscriber.sources_dirty = false;
subscriber
.ensure_streams()
.await
.expect("clean reconcile must be a no-op");
assert!(
matches!(subscriber.state, AlloySubscriberState::Empty),
"not-dirty ensure_streams must not connect new sources"
);
}
}
fn resolve_subscriber_transport(
mode: SubscriberMode,
) -> Result<SubscriberTransport, SubscriberError> {
match mode {
SubscriberMode::PubSub => {
#[cfg(feature = "reactive-ws")]
{
Ok(SubscriberTransport::PubSub)
}
#[cfg(not(feature = "reactive-ws"))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber pubsub mode requires the reactive-ws feature",
))
}
}
SubscriberMode::Polling => {
#[cfg(feature = "reactive-polling")]
{
Ok(SubscriberTransport::Polling)
}
#[cfg(not(feature = "reactive-polling"))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber polling mode requires the reactive-polling feature",
))
}
}
SubscriberMode::Auto => resolve_auto_subscriber_transport(),
}
}
fn resolve_auto_subscriber_transport() -> Result<SubscriberTransport, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
Ok(SubscriberTransport::PubSub)
}
#[cfg(all(not(feature = "reactive-ws"), feature = "reactive-polling"))]
{
Ok(SubscriberTransport::Polling)
}
#[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))]
{
Err(SubscriberError::Unsupported(
"AlloySubscriber requires either reactive-ws or reactive-polling",
))
}
}
fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), SubscriberError> {
if config.max_batch_size == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_batch_size must be greater than zero",
));
}
if config.max_log_addresses_per_subscription == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
));
}
if config.reconnect.enabled {
if config.reconnect.retry_delay > config.reconnect.max_delay {
return Err(SubscriberError::InvalidConfig(
"SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
));
}
if matches!(config.reconnect.max_attempts, Some(0)) {
return Err(SubscriberError::InvalidConfig(
"SubscriberReconnectConfig::max_attempts must be greater than zero when set",
));
}
}
Ok(())
}
fn validate_supported_interests<N: Network>(
mode: SubscriberMode,
config: &SubscriberConfig,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError> {
let transport = resolve_subscriber_transport(mode)?;
for interest in interests {
match interest {
ReactiveInterest::Logs(_) => {}
ReactiveInterest::PendingTransactions(interest)
if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
ReactiveInterest::PendingTransactions(_) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
));
}
ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
(SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
(_, BlockInterestMode::FullBlock) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber full block streams are not implemented in this transport slice",
));
}
(SubscriberTransport::Polling, BlockInterestMode::Header) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber polling block streams are not implemented in this transport slice",
));
}
},
}
}
Ok(())
}
fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
let mut filters = Vec::new();
for interest in interests {
if let ReactiveInterest::Logs(interest) = interest {
merge_log_subscription_filter(&mut filters, &interest.provider_filter);
}
}
filters
}
fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::Blocks(BlockInterest {
mode: BlockInterestMode::Header,
})
)
})
}
fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
)
})
}
fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::Logs(interest) if interest.matches(log)
)
})
}
fn validate_owner_backfill_logs(
logs: &[Log],
from_block: u64,
through: &BlockRef,
) -> Result<(), SubscriberOwnerError> {
for log in logs {
if log.removed {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"removed log in canonical catch-up",
));
}
let number = log
.block_number
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing block number",
))?;
let hash = log
.block_hash
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing block hash",
))?;
log.transaction_hash
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing transaction hash",
))?;
log.transaction_index
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing transaction index",
))?;
log.log_index
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing log index",
))?;
if number < from_block || number > through.number {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"log outside requested block range",
));
}
if number == through.number && hash != through.hash {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"target-block log hash mismatch",
));
}
}
Ok(())
}
fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
let mut positions = HashMap::new();
let mut block_hashes = HashMap::new();
let mut transaction_hashes = HashMap::new();
let mut transaction_positions = HashMap::new();
let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
for log in logs {
let number = log
.block_number
.expect("individual owner catch-up logs are validated before set validation");
let block_hash = log
.block_hash
.expect("individual owner catch-up logs are validated before set validation");
let transaction_hash = log
.transaction_hash
.expect("individual owner catch-up logs are validated before set validation");
let transaction_index = log
.transaction_index
.expect("individual owner catch-up logs are validated before set validation");
let log_index = log
.log_index
.expect("individual owner catch-up logs are validated before set validation");
if block_hashes
.insert(number, block_hash)
.is_some_and(|prior| prior != block_hash)
{
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting block identity in canonical catch-up",
));
}
if let Some(previous) = positions.insert((number, log_index), log)
&& previous != log
{
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting logs at one canonical block position",
));
}
let conflicting_transaction = transaction_hashes
.insert((number, transaction_index), transaction_hash)
.is_some_and(|prior| prior != transaction_hash)
|| transaction_positions
.insert((number, transaction_hash), transaction_index)
.is_some_and(|prior| prior != transaction_index);
if conflicting_transaction {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting transaction identity at one canonical block position",
));
}
ordering
.entry(number)
.or_default()
.push((log_index, transaction_index));
}
for positions in ordering.values_mut() {
positions.sort_unstable();
if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"transaction and log positions disagree on canonical order",
));
}
}
Ok(())
}
fn merged_owner_reconcile_filters<N: Network>(
plans: &[SubscriberOwnerReconcilePlan<N>],
through: u64,
) -> Vec<SubscriberOwnerReconcileFilter> {
let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
for plan in plans.iter().filter(|plan| plan.from_block <= through) {
let filters = by_start.entry(plan.from_block).or_default();
filters.extend(
log_filters(&plan.interests)
.into_iter()
.map(|filter| filter.from_block(plan.from_block).to_block(through)),
);
}
let mut chunks = Vec::new();
for (from_block, filters) in by_start {
for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
let mut merged = Vec::new();
for filter in filters {
merge_log_subscription_filter(&mut merged, filter);
}
chunks.extend(
merged
.into_iter()
.map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
);
}
}
chunks
}
async fn fetch_owner_catchup<P, N>(
provider: P,
filters: Vec<SubscriberOwnerReconcileFilter>,
retained: Vec<BlockRef>,
through: BlockRef,
) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
where
P: Provider<N> + Send + Sync,
N: Network,
{
let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
let mut certified_positions = HashSet::new();
for position in retained {
let target_certifies_position = position == through
|| (position.number.checked_add(1) == Some(through.number)
&& through.parent_hash == Some(position.hash));
if !target_certifies_position && certified_positions.insert(position.clone()) {
let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
}
}
let mut logs = Vec::new();
let requests = filters.into_iter().map(|filter| {
let provider = &provider;
async move {
let logs = provider
.get_logs(&filter.filter)
.await
.map_err(provider_error)?;
Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
}
});
for (from_block, fetched) in try_join_all(requests).await? {
validate_owner_backfill_logs(&fetched, from_block, &through)?;
logs.extend(fetched);
}
validate_owner_backfill_log_set(&logs)?;
let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
Ok(SubscriberOwnerCatchup { logs, certified })
}
async fn verify_provider_reconcile_target<P, N>(
provider: &P,
expected: &BlockRef,
) -> Result<BlockRef, SubscriberOwnerError>
where
P: Provider<N> + Send + Sync,
N: Network,
{
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(expected.number))
.await
.map_err(provider_error)?
.ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
let header = block.header();
let actual = BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
let exact_parent = expected
.parent_hash
.is_none_or(|parent| Some(parent) == actual.parent_hash);
let exact_timestamp = expected
.timestamp
.is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
if actual.number != expected.number
|| actual.hash != expected.hash
|| !exact_parent
|| !exact_timestamp
{
return Err(SubscriberOwnerError::BlockMismatch {
expected_number: expected.number,
expected_hash: expected.hash,
actual_number: actual.number,
actual_hash: actual.hash,
});
}
Ok(actual)
}
fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
let context = log_reactive_context(&log);
ReactiveInputRecord::new(
ReactiveInput::Log(log),
ReactiveContext { source, ..context },
)
}
fn log_reactive_context(log: &Log) -> ReactiveContext {
let block = match (log.block_hash, log.block_number) {
(Some(hash), Some(number)) => Some(BlockRef {
number,
hash,
parent_hash: None,
timestamp: log.block_timestamp,
}),
_ => None,
};
let chain_status = match (&block, log.removed) {
(Some(block), true) => ChainStatus::Reorged {
dropped_from: block.clone(),
},
(Some(block), false) => ChainStatus::Included {
block: block.clone(),
confirmations: 0,
},
(None, _) => ChainStatus::Pending,
};
ReactiveContext {
chain_id: None,
source: InputSource::Poll,
chain_status,
block,
transaction_index: log.transaction_index,
log_index: log.log_index,
}
}
fn block_header_input_record<N>(header: N::HeaderResponse) -> ReactiveInputRecord<N>
where
N: Network,
{
let block = BlockRef {
number: header.number(),
hash: HeaderResponseTrait::hash(&header),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
ReactiveInputRecord::new(
ReactiveInput::BlockHeader(header),
ReactiveContext {
chain_id: None,
source: InputSource::Subscription,
chain_status: ChainStatus::Included {
block: block.clone(),
confirmations: 0,
},
block: Some(block),
transaction_index: None,
log_index: None,
},
)
}
fn pending_hash_input_record<N: Network>(
hash: B256,
source: InputSource,
) -> ReactiveInputRecord<N> {
ReactiveInputRecord::new(
ReactiveInput::PendingTxHash(hash),
ReactiveContext {
chain_id: None,
source,
chain_status: ChainStatus::Pending,
block: None,
transaction_index: None,
log_index: None,
},
)
}
fn provider_error(error: impl fmt::Display) -> SubscriberError {
SubscriberError::Provider(error.to_string())
}
#[derive(Debug, thiserror::Error)]
pub enum SubscriberError {
#[error("{0}")]
InvalidConfig(&'static str),
#[error("{0}")]
Unsupported(&'static str),
#[error("provider error: {0}")]
Provider(String),
}