use std::{
any::Any,
borrow::Cow,
collections::{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;
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::poll_fn, 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 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>),
}
#[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: Vec<RegisteredHandler<N>>,
}
struct RegisteredHandler<N: Network = Ethereum> {
id: HandlerId,
handler: Arc<dyn ReactiveHandler<N>>,
interests: Vec<ReactiveInterest<N>>,
}
impl<N: Network> Default for ReactiveRegistry<N> {
fn default() -> Self {
Self::new()
}
}
impl<N: Network> ReactiveRegistry<N> {
pub fn new() -> Self {
Self {
handlers: Vec::new(),
}
}
pub fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), RegisterError> {
let id = handler.id();
if self.handlers.iter().any(|registered| registered.id == id) {
return Err(RegisterError::DuplicateHandler(id));
}
let interests = handler.interests();
self.handlers.push(RegisteredHandler {
id,
handler,
interests,
});
Ok(())
}
pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
let index = self
.handlers
.iter()
.position(|registered| ®istered.id == id)?;
Some(self.handlers.remove(index).handler)
}
pub fn contains_handler(&self, id: &HandlerId) -> bool {
self.handlers.iter().any(|registered| ®istered.id == id)
}
pub fn handler_ids(&self) -> Vec<HandlerId> {
self.handlers
.iter()
.map(|registered| registered.id.clone())
.collect()
}
pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.handlers
.iter()
.find(|registered| ®istered.id == id)
.map(|registered| registered.interests.as_slice())
}
pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
self.handlers
.iter()
.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.handlers
.iter()
.filter_map(|handler| handler.route_log(log))
.collect()
}
fn handlers(&self) -> &[RegisteredHandler<N>] {
&self.handlers
}
fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
self.handlers.iter().flat_map(|handler| {
handler
.interests
.iter()
.filter_map(|interest| match interest {
ReactiveInterest::Logs(interest) => Some(interest),
ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
})
})
}
}
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 pending_resyncs(&self) -> &[ResyncRequest] {
&self.pending_resyncs
}
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();
for registered in self.registry.handlers() {
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>,
>;
#[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 reconnect: SubscriberReconnectConfig,
}
impl Default for SubscriberConfig {
fn default() -> Self {
Self {
hydrate_pending_transactions: false,
max_batch_size: 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
}
}
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>>,
interests: Vec<ReactiveInterest<N>>,
log_source_ids: HashMap<Filter, usize>,
next_log_source_id: usize,
pending_backfills: VecDeque<QueuedSubscriberBackfill>,
sources_dirty: bool,
state: AlloySubscriberState<N>,
pending_records: VecDeque<ReactiveInputRecord<N>>,
last_seen_log_blocks: HashMap<usize, u64>,
recent_input_refs: VecDeque<InputRef>,
recent_input_ref_set: HashSet<InputRef>,
_network: PhantomData<N>,
}
struct OwnedSubscriberInterests<N: Network = Ethereum> {
owner: HandlerId,
interests: Vec<ReactiveInterest<N>>,
}
struct QueuedSubscriberBackfill {
owner: HandlerId,
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(),
interests: Vec::new(),
log_source_ids: HashMap::new(),
next_log_source_id: 0,
pending_backfills: VecDeque::new(),
sources_dirty: true,
state: AlloySubscriberState::Uninitialized,
pending_records: VecDeque::new(),
last_seen_log_blocks: HashMap::new(),
recent_input_refs: VecDeque::new(),
recent_input_ref_set: HashSet::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 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).interests;
self.pending_backfills
.retain(|backfill| &backfill.owner != owner);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
Some(removed)
}
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();
match next_owned.iter_mut().find(|entry| entry.owner == owner) {
Some(entry) => entry.interests = interests.to_vec(),
None => next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
}),
}
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;
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(),
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(),
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(),
})
.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> {
let id = self.log_source_ids.get(filter)?;
self.last_seen_log_blocks.get(id).copied()
}
#[allow(clippy::mutable_key_type)]
fn log_stream_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
}
#[allow(clippy::mutable_key_type)]
fn retire_unreferenced_filters(&mut self) {
let live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
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_batch(&mut self) -> Option<ReactiveInputBatch<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(ReactiveInputBatch::new(records))
}
fn reset_delivery_state(&mut self) {
self.pending_records.clear();
self.last_seen_log_blocks.clear();
self.recent_input_refs.clear();
self.recent_input_ref_set.clear();
self.pending_backfills.clear();
self.log_source_ids.clear();
self.next_log_source_id = 0;
self.sources_dirty = true;
}
}
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 {
if let Some(batch) = self.drain_next_batch() {
return Ok(Some(batch));
}
self.drain_pending_backfills().await?;
if let Some(batch) = self.drain_next_batch() {
return Ok(Some(batch));
}
self.ensure_streams().await?;
if let Some(batch) = self.drain_next_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_batch() {
return Ok(Some(batch));
}
}
})
}
}
impl<P, N> AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
if !self.sources_dirty {
return Ok(());
}
if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
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.sources_dirty = false;
Ok(())
}
async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
while let Some(queued) = self.pending_backfills.front() {
if self.owner_interests(&queued.owner).is_none() {
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));
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();
for filter in self.log_stream_filters() {
let id = self.log_source_id(&filter);
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) => {
if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
return Ok(Some(backfill_event));
}
}
event => return Ok(Some(event)),
}
}
}
fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
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(record);
}
}
SubscriberEvent::BackfilledLogs { source_id, logs } => {
self.enqueue_backfilled_logs(logs, Some(source_id));
}
SubscriberEvent::Logs(logs) => self.pending_records.extend(
logs.into_iter()
.filter(|log| log_matches_any_interest(log, &self.interests))
.map(|log| log_input_record(log, InputSource::Poll)),
),
SubscriberEvent::BlockHeader(header) => {
if needs_header_block_stream(&self.interests) {
let record = block_header_input_record::<N>(header);
self.enqueue_record(record);
}
}
SubscriberEvent::PendingHash(hash) => {
let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
self.enqueue_record(record);
}
SubscriberEvent::PendingHashes(hashes) => self.pending_records.extend(
hashes
.into_iter()
.map(|hash| pending_hash_input_record::<N>(hash, InputSource::Poll)),
),
SubscriberEvent::StreamTerminated(_) => {}
}
}
fn enqueue_backfilled_logs(&mut self, logs: Vec<Log>, source_id: Option<usize>) {
for log in logs {
if log_matches_any_interest(&log, &self.interests) {
let record = log_input_record(log, InputSource::Backfill);
if let Some(source_id) = source_id {
self.note_log_block(source_id, &record);
}
self.enqueue_record(record);
}
}
}
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(&mut self, record: ReactiveInputRecord<N>) {
if self.should_skip_recent_duplicate(&record) {
return;
}
self.remember_record(&record);
self.pending_records.push_back(record);
}
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]
#[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 _ = subscriber.stream_sources().expect("stream sources");
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
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_do_not_merge_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(), 2);
assert!(
expanded_sources
.iter()
.any(|source| source.same_key(&pool_a_source)),
"adding pool-b should not rewrite pool-a's stream source"
);
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));
}
#[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(), 2);
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.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 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),
}