use std::{
any::Any,
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
fmt,
future::Future,
hash::Hash,
marker::PhantomData,
num::NonZeroU64,
path::PathBuf,
pin::Pin,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use alloy_consensus::{BlockHeader as _, Transaction as _};
use alloy_eips::{BlockId, BlockNumberOrTag};
use alloy_network::{
Ethereum, Network,
primitives::{
BlockResponse as _, HeaderResponse as HeaderResponseTrait,
TransactionResponse as TransactionResponseTrait,
},
};
use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256};
use alloy_provider::{Provider, RootProvider};
use alloy_rpc_client::BatchRequest;
use alloy_rpc_types_eth::{Filter, FilterSet, Log};
pub use alloy_transport_balancer::EndpointId;
use bincode::Options;
use futures::{StreamExt, stream};
use futures::{
future::{Either, poll_fn, select},
stream::{BoxStream, FuturesUnordered},
};
use crate::{
cache::{
AccountProof, BlockStateDiff, DurableCheckpointBlock, DurableCheckpointError,
DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache,
EvmCacheStateSnapshot, LoadedDurableCheckpoint,
},
errors::{BlockContextError, StorageFetchResult},
events::{EventDecoder, StateView},
freshness::FreshnessRegistry,
state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate},
};
#[cfg(feature = "raw-flashblocks-json")]
mod raw_json_flashblocks;
#[cfg(feature = "raw-flashblocks-json")]
pub use raw_json_flashblocks::{
FlashblockInvalidation, FlashblockInvalidationReason, FlashblockSnapshot, FlashblockUpdate,
FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError, FlashblockUpdateSender,
RawJsonFlashblocksAdapter, RawJsonFlashblocksError, RawJsonFlashblocksLimits,
};
#[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, serde::Serialize, serde::Deserialize)]
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, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct BlockRef {
pub number: u64,
pub hash: B256,
pub parent_hash: Option<B256>,
pub timestamp: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ProviderRef {
pub endpoint: EndpointId,
pub generation: u64,
}
impl ProviderRef {
pub fn new(endpoint: impl Into<EndpointId>, generation: u64) -> Self {
Self {
endpoint: endpoint.into(),
generation,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FlashblockRef {
pub provider: ProviderRef,
pub payload_id: Option<FixedBytes<8>>,
pub index: Option<u64>,
pub block_number: u64,
pub content_hash: B256,
pub partial_block_hash: Option<B256>,
pub parent_hash: Option<B256>,
pub state_root: Option<B256>,
pub transactions_root: Option<B256>,
pub transaction_hashes: Vec<B256>,
pub timestamp: Option<u64>,
pub base_fee_per_gas: Option<u64>,
pub beneficiary: Option<Address>,
pub prevrandao: Option<B256>,
pub gas_limit: Option<u64>,
}
impl FlashblockRef {
pub const fn block_ref(&self) -> BlockRef {
BlockRef {
number: self.block_number,
hash: self.content_hash,
parent_hash: self.parent_hash,
timestamp: self.timestamp,
}
}
pub fn contains_transaction(&self, transaction_hash: &B256) -> bool {
self.transaction_hashes.contains(transaction_hash)
}
fn transaction_index(&self, transaction_hash: &B256) -> Option<u64> {
self.transaction_hashes
.iter()
.position(|candidate| candidate == transaction_hash)
.and_then(|index| u64::try_from(index).ok())
}
fn same_payload(&self, other: &Self) -> bool {
self.provider == other.provider
&& match (self.payload_id, other.payload_id) {
(Some(left), Some(right)) => left == right,
_ => {
self.block_number == other.block_number && self.parent_hash == other.parent_hash
}
}
}
#[cfg(feature = "raw-flashblocks-json")]
fn same_base_identity(&self, other: &Self) -> bool {
self.block_number == other.block_number
&& self.parent_hash == other.parent_hash
&& self.timestamp == other.timestamp
&& self.base_fee_per_gas == other.base_fee_per_gas
&& self.beneficiary == other.beneficiary
&& self.prevrandao == other.prevrandao
&& self.gas_limit == other.gas_limit
}
fn is_cumulative_successor_of(&self, previous: &Self) -> bool {
self.same_payload(previous)
&& self.transaction_hashes.len() >= previous.transaction_hashes.len()
&& self
.transaction_hashes
.starts_with(&previous.transaction_hashes)
&& match (previous.index, self.index) {
(Some(previous), Some(current)) => current >= previous,
_ => true,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum PreconfirmationMode {
#[default]
Disabled,
Preferred,
Required,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
pub struct BaseFlashblockPayload {
pub payload_id: FixedBytes<8>,
pub index: u64,
pub base: Option<BaseFlashblockBase>,
pub diff: BaseFlashblockDiff,
#[serde(default)]
pub metadata: Option<BaseFlashblockMetadata>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
pub struct BaseFlashblockBase {
pub parent_hash: B256,
#[serde(deserialize_with = "deserialize_rpc_u64")]
pub block_number: u64,
#[serde(deserialize_with = "deserialize_rpc_u64")]
pub timestamp: u64,
#[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
pub gas_limit: Option<u64>,
#[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
pub base_fee_per_gas: Option<u64>,
#[serde(default, alias = "fee_recipient", alias = "feeRecipient")]
pub beneficiary: Option<Address>,
#[serde(
default,
alias = "prev_randao",
alias = "prevRandao",
alias = "mixHash"
)]
pub prevrandao: Option<B256>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
pub struct BaseFlashblockDiff {
pub state_root: B256,
pub block_hash: B256,
#[serde(default)]
pub transactions: Vec<serde_json::Value>,
#[serde(default)]
pub transactions_root: Option<B256>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
pub struct BaseFlashblockMetadata {
#[serde(deserialize_with = "deserialize_rpc_u64")]
pub block_number: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct BaseFlashblockBlockPayload {
hash: B256,
#[serde(deserialize_with = "deserialize_rpc_u64")]
number: u64,
parent_hash: B256,
state_root: B256,
#[serde(default)]
transactions_root: Option<B256>,
#[serde(default)]
transactions: Vec<serde_json::Value>,
#[serde(deserialize_with = "deserialize_rpc_u64")]
timestamp: u64,
#[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
base_fee_per_gas: Option<u64>,
#[serde(default, alias = "beneficiary", alias = "feeRecipient")]
miner: Option<Address>,
#[serde(default, alias = "prevRandao")]
mix_hash: Option<B256>,
#[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
gas_limit: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(untagged)]
enum BaseFlashblockWirePayload {
Indexed(BaseFlashblockPayload),
Block(BaseFlashblockBlockPayload),
}
fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum RpcU64 {
Number(u64),
String(String),
}
match <RpcU64 as serde::Deserialize>::deserialize(deserializer)? {
RpcU64::Number(number) => Ok(number),
RpcU64::String(value) => {
let value = value.strip_prefix("0x").unwrap_or(&value);
u64::from_str_radix(value, 16).map_err(serde::de::Error::custom)
}
}
}
fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum RpcU64 {
Number(u64),
String(String),
}
let Some(value) = <Option<RpcU64> as serde::Deserialize>::deserialize(deserializer)? else {
return Ok(None);
};
match value {
RpcU64::Number(number) => Ok(Some(number)),
RpcU64::String(value) => {
let value = value.strip_prefix("0x").unwrap_or(&value);
u64::from_str_radix(value, 16)
.map(Some)
.map_err(serde::de::Error::custom)
}
}
}
fn non_placeholder_hash(hash: B256) -> Option<B256> {
(!hash.is_zero()).then_some(hash)
}
fn flashblock_transaction_hashes(
transactions: &[serde_json::Value],
) -> Result<Vec<B256>, SubscriberError> {
let hashes: Vec<B256> = transactions
.iter()
.map(|transaction| {
let value = match transaction {
serde_json::Value::String(value) => value.as_str(),
serde_json::Value::Object(object) => object
.get("hash")
.or_else(|| object.get("transactionHash"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
SubscriberError::Provider(
"Flashblock transaction object is missing its hash".into(),
)
})?,
_ => {
return Err(SubscriberError::Provider(
"Flashblock transaction must be a hash, raw transaction, or object".into(),
));
}
};
if value.len() == 66 {
return value.parse::<B256>().map_err(|error| {
SubscriberError::Provider(format!(
"Flashblock transaction hash is invalid: {error}"
))
});
}
let encoded = value.strip_prefix("0x").unwrap_or(value);
let raw = alloy_primitives::hex::decode(encoded).map_err(|error| {
SubscriberError::Provider(format!(
"Flashblock raw transaction is invalid hex: {error}"
))
})?;
Ok(alloy_primitives::keccak256(raw))
})
.collect::<Result<_, _>>()?;
let mut unique = HashSet::with_capacity(hashes.len());
if hashes.iter().any(|hash| !unique.insert(*hash)) {
return Err(SubscriberError::Provider(
"Flashblock cumulative transaction membership contains a duplicate hash".into(),
));
}
Ok(hashes)
}
struct FlashblockContentCommitment<'a> {
provider: &'a ProviderRef,
payload_id: Option<FixedBytes<8>>,
index: Option<u64>,
block_number: u64,
partial_block_hash: Option<B256>,
parent_hash: Option<B256>,
state_root: Option<B256>,
transactions_root: Option<B256>,
transaction_hashes: &'a [B256],
timestamp: Option<u64>,
base_fee_per_gas: Option<u64>,
beneficiary: Option<Address>,
prevrandao: Option<B256>,
gas_limit: Option<u64>,
}
fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 {
let mut commitment = Keccak256::new();
commitment.update(b"evm-fork-cache/flashblock-content/v1");
let endpoint = content.provider.endpoint.as_str().as_bytes();
commitment.update((endpoint.len() as u64).to_be_bytes());
commitment.update(endpoint);
commitment.update(content.provider.generation.to_be_bytes());
commitment.update(content.block_number.to_be_bytes());
commit_optional_bytes(
&mut commitment,
content.payload_id.as_ref().map(FixedBytes::as_slice),
);
commit_optional_u64(&mut commitment, content.index);
commit_optional_bytes(
&mut commitment,
content
.partial_block_hash
.as_ref()
.map(FixedBytes::as_slice),
);
commit_optional_bytes(
&mut commitment,
content.parent_hash.as_ref().map(FixedBytes::as_slice),
);
commit_optional_bytes(
&mut commitment,
content.state_root.as_ref().map(FixedBytes::as_slice),
);
commit_optional_bytes(
&mut commitment,
content.transactions_root.as_ref().map(FixedBytes::as_slice),
);
commitment.update((content.transaction_hashes.len() as u64).to_be_bytes());
for transaction_hash in content.transaction_hashes {
commitment.update(transaction_hash);
}
commit_optional_u64(&mut commitment, content.timestamp);
commit_optional_u64(&mut commitment, content.base_fee_per_gas);
commit_optional_bytes(
&mut commitment,
content
.beneficiary
.as_ref()
.map(|address| address.as_slice()),
);
commit_optional_bytes(
&mut commitment,
content.prevrandao.as_ref().map(FixedBytes::as_slice),
);
commit_optional_u64(&mut commitment, content.gas_limit);
let hash = commitment.finalize();
if hash.is_zero() {
B256::with_last_byte(1)
} else {
hash
}
}
#[cfg(feature = "raw-flashblocks-json")]
fn validate_standard_flashblock_snapshot(
snapshot: &FlashblockSnapshot,
) -> Result<(), SubscriberError> {
let flashblock = &snapshot.flashblock;
if flashblock.payload_id.is_none() || flashblock.index.is_none() {
return Err(SubscriberError::Provider(
"external Flashblock snapshot is missing its indexed payload identity".into(),
));
}
let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment {
provider: &flashblock.provider,
payload_id: flashblock.payload_id,
index: flashblock.index,
block_number: flashblock.block_number,
partial_block_hash: flashblock.partial_block_hash,
parent_hash: flashblock.parent_hash,
state_root: flashblock.state_root,
transactions_root: flashblock.transactions_root,
transaction_hashes: &flashblock.transaction_hashes,
timestamp: flashblock.timestamp,
base_fee_per_gas: flashblock.base_fee_per_gas,
beneficiary: flashblock.beneficiary,
prevrandao: flashblock.prevrandao,
gas_limit: flashblock.gas_limit,
});
if flashblock.content_hash != expected_content_hash {
return Err(SubscriberError::Provider(
"external Flashblock content commitment is invalid".into(),
));
}
let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len());
if flashblock
.transaction_hashes
.iter()
.any(|hash| !transactions.insert(*hash))
{
return Err(SubscriberError::Provider(
"external Flashblock cumulative transaction membership contains a duplicate hash"
.into(),
));
}
let mut log_ids = HashSet::with_capacity(snapshot.logs.len());
for log in &snapshot.logs {
if log.removed || log.block_number != Some(flashblock.block_number) {
return Err(SubscriberError::Provider(
"external pre-confirmed log disagrees with its Flashblock block identity".into(),
));
}
if log.block_hash != Some(flashblock.content_hash) {
return Err(SubscriberError::Provider(
"external pre-confirmed log is not bound to its Flashblock content commitment"
.into(),
));
}
let transaction_hash = log.transaction_hash.ok_or_else(|| {
SubscriberError::Provider(
"external pre-confirmed log is missing its transaction hash".into(),
)
})?;
let expected_transaction_index = flashblock
.transaction_index(&transaction_hash)
.ok_or_else(|| {
SubscriberError::Provider(
"external pre-confirmed log transaction is absent from the cumulative Flashblock"
.into(),
)
})?;
if log.transaction_index != Some(expected_transaction_index) {
return Err(SubscriberError::Provider(
"external pre-confirmed log transaction index disagrees with cumulative membership"
.into(),
));
}
let log_index = log.log_index.ok_or_else(|| {
SubscriberError::Provider("external pre-confirmed log is missing its log index".into())
})?;
if !log_ids.insert((transaction_hash, log_index)) {
return Err(SubscriberError::Provider(
"external Flashblock snapshot contains a duplicate log identity".into(),
));
}
}
Ok(())
}
fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
match value {
Some(value) => {
commitment.update([1]);
commitment.update((value.len() as u64).to_be_bytes());
commitment.update(value);
}
None => commitment.update([0]),
}
}
fn commit_optional_u64(commitment: &mut Keccak256, value: Option<u64>) {
match value {
Some(value) => {
commitment.update([1]);
commitment.update(value.to_be_bytes());
}
None => commitment.update([0]),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ReactiveCanonicalBaseline {
pub chain_id: u64,
pub block: BlockRef,
}
impl ReactiveCanonicalBaseline {
pub const fn new(chain_id: u64, block: BlockRef) -> Self {
Self { chain_id, block }
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum ChainControl {
Reorg {
common_ancestor: BlockRef,
old_tip: BlockRef,
new_tip: BlockRef,
},
Safe(BlockRef),
Finalized(BlockRef),
CanonicalProgress(BlockRef),
Barrier {
id: Vec<u8>,
block: Option<BlockRef>,
},
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CanonicalSequenceState {
retained_canonical_history: Vec<BlockRef>,
coverage_head: Option<BlockRef>,
safe_head: Option<BlockRef>,
finalized_head: Option<BlockRef>,
}
impl CanonicalSequenceState {
pub fn new(
retained_canonical_history: Vec<BlockRef>,
coverage_head: Option<BlockRef>,
safe_head: Option<BlockRef>,
finalized_head: Option<BlockRef>,
) -> Self {
Self {
retained_canonical_history,
coverage_head,
safe_head,
finalized_head,
}
}
pub fn retained_canonical_history(&self) -> &[BlockRef] {
&self.retained_canonical_history
}
pub const fn coverage_head(&self) -> Option<&BlockRef> {
self.coverage_head.as_ref()
}
pub const fn safe_head(&self) -> Option<&BlockRef> {
self.safe_head.as_ref()
}
pub const fn finalized_head(&self) -> Option<&BlockRef> {
self.finalized_head.as_ref()
}
pub fn retain_recent_history(&mut self, max_entries: usize) {
let remove = self
.retained_canonical_history
.len()
.saturating_sub(max_entries);
self.retained_canonical_history.drain(..remove);
}
pub fn validate(&self) -> Result<(), ReactiveError> {
validate_canonical_sequence_snapshot(self)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CanonicalSequenceMutation {
Rewind {
common_ancestor: Option<BlockRef>,
dropped: Vec<BlockRef>,
},
Canonical(BlockRef),
Safe(BlockRef),
Finalized(BlockRef),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanonicalSequenceValidation {
pre_record_state: CanonicalSequenceState,
next_state: CanonicalSequenceState,
mutations: Vec<CanonicalSequenceMutation>,
normalized_chain_controls: Vec<ChainControl>,
}
impl CanonicalSequenceValidation {
pub const fn pre_record_state(&self) -> &CanonicalSequenceState {
&self.pre_record_state
}
pub const fn next_state(&self) -> &CanonicalSequenceState {
&self.next_state
}
pub fn mutations(&self) -> &[CanonicalSequenceMutation] {
&self.mutations
}
pub fn normalized_chain_controls(&self) -> &[ChainControl] {
&self.normalized_chain_controls
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum ChainStatus {
Pending,
Preconfirmed {
flashblock: Arc<FlashblockRef>,
},
Included {
block: BlockRef,
confirmations: u64,
},
Safe {
block: BlockRef,
},
Finalized {
block: BlockRef,
},
Reorged {
dropped_from: BlockRef,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum InputSource {
Batch,
Subscription,
Poll,
Backfill,
Flashblocks,
Synthetic,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum ReactiveInputKind {
CanonicalLog,
ReorgSignalLog,
BlockHeader,
FullBlock,
PendingTxHash,
PendingTx,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ReactiveInputIdentity {
input_ref: InputRef,
kind: ReactiveInputKind,
}
impl ReactiveInputIdentity {
pub fn try_from_parts(
input_ref: InputRef,
kind: ReactiveInputKind,
) -> Result<Self, ReactiveInputIdentityError> {
let compatible = matches!(
(input_ref, kind),
(
InputRef::Log { .. },
ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog
) | (
InputRef::Block { .. },
ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock
) | (
InputRef::PendingTx { .. },
ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx
)
);
if !compatible {
return Err(ReactiveInputIdentityError { input_ref, kind });
}
Ok(Self { input_ref, kind })
}
pub const fn input_ref(&self) -> InputRef {
self.input_ref
}
pub const fn kind(&self) -> ReactiveInputKind {
self.kind
}
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")]
pub struct ReactiveInputIdentityError {
input_ref: InputRef,
kind: ReactiveInputKind,
}
impl ReactiveInputIdentityError {
pub const fn input_ref(&self) -> InputRef {
self.input_ref
}
pub const fn kind(&self) -> ReactiveInputKind {
self.kind
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StateEffectQuality {
ExactFromInput,
AppliedWithPendingResync,
ResyncedAuthoritatively,
RequiresRepair,
NoStateEffect,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
pub struct HandlerId(String);
impl HandlerId {
pub fn new(id: impl Into<String>) -> Self {
Self::try_new(id).expect("handler id must not be empty")
}
pub fn try_new(id: impl Into<String>) -> Result<Self, HandlerIdError> {
let id = id.into();
if id.is_empty() {
return Err(HandlerIdError);
}
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> serde::Deserialize<'de> for HandlerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::try_new(id).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
#[error("handler id must not be empty")]
pub struct HandlerIdError;
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,
pub provider: Option<ProviderRef>,
}
impl<N: Network> ReactiveInputRecord<N> {
pub fn new(input: ReactiveInput<N>, context: ReactiveContext) -> Self {
Self {
input,
context,
provider: None,
}
}
#[must_use]
pub fn with_provider(mut self, provider: ProviderRef) -> Self {
self.provider = Some(provider);
self
}
pub fn input_ref(&self) -> InputRef {
input_ref(&self.input, &self.context)
}
pub fn validated_identity(&self) -> Result<ReactiveInputIdentity, ReactiveError> {
validate_input_record(self)?;
let kind = match &self.input {
ReactiveInput::Log(log)
if log.removed
|| matches!(self.context.chain_status, ChainStatus::Reorged { .. }) =>
{
ReactiveInputKind::ReorgSignalLog
}
ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog,
ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader,
ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock,
ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash,
ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx,
};
ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| {
ReactiveError::InvalidInputRecord {
message: error.to_string(),
}
})
}
pub fn same_deduplicable_payload(&self, other: &Self) -> bool {
match (&self.input, &other.input) {
(ReactiveInput::Log(left), ReactiveInput::Log(right)) => {
left.inner == right.inner
&& left.block_hash == right.block_hash
&& left.block_number == right.block_number
&& optional_metadata_compatible(
left.block_timestamp.as_ref(),
right.block_timestamp.as_ref(),
)
&& left.transaction_hash == right.transaction_hash
&& left.transaction_index == right.transaction_index
&& left.log_index == right.log_index
&& left.removed == right.removed
}
(ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => {
left.hash() == right.hash()
}
(ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false,
(ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => {
left == right
}
(ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false,
_ => false,
}
}
pub fn is_payload_deduplicable(&self) -> bool {
matches!(
&self.input,
ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_)
)
}
pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result<bool, ReactiveError> {
let identity = self.validated_identity()?;
let other_identity = other.validated_identity()?;
if identity != other_identity
|| !self.is_payload_deduplicable()
|| !other.is_payload_deduplicable()
{
return Ok(false);
}
if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) {
return Err(ReactiveError::InvalidInputRecord {
message: format!(
"conflicting payload or semantic context for identity {identity:?}"
),
});
}
let mut merged = self.clone();
merge_deduplicable_record(&mut merged, other);
merged.validated_identity()?;
*self = merged;
Ok(true)
}
pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool {
let left = &self.context;
let right = &other.context;
left.chain_id == right.chain_id
&& optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref())
&& left.transaction_index == right.transaction_index
&& left.log_index == right.log_index
&& chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status)
}
}
fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool {
match (left, right) {
(ChainStatus::Pending, ChainStatus::Pending)
| (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true,
(
ChainStatus::Preconfirmed { flashblock: left },
ChainStatus::Preconfirmed { flashblock: right },
) => left == right,
(
ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. },
) => true,
_ => false,
}
}
fn optional_metadata_compatible<T: PartialEq>(left: Option<&T>, right: Option<&T>) -> bool {
left.zip(right).is_none_or(|(left, right)| left == right)
}
fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool {
match (left, right) {
(None, None) => true,
(Some(left), Some(right)) => {
left.number == right.number
&& left.hash == right.hash
&& optional_metadata_compatible(
left.parent_hash.as_ref(),
right.parent_hash.as_ref(),
)
&& optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref())
}
_ => false,
}
}
fn merge_deduplicable_record<N: Network>(
retained: &mut ReactiveInputRecord<N>,
incoming: &ReactiveInputRecord<N>,
) {
if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) =
(&mut retained.input, &incoming.input)
&& retained.block_timestamp.is_none()
{
retained.block_timestamp = incoming.block_timestamp;
}
if let (Some(retained), Some(incoming)) =
(&mut retained.context.block, incoming.context.block.as_ref())
{
enrich_block_ref(retained, incoming);
}
retained.context.chain_status = merged_chain_status(
&retained.context.chain_status,
&incoming.context.chain_status,
);
if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) {
retained.context.source = incoming.context.source;
}
if retained.provider.is_none() {
retained.provider = incoming.provider.clone();
}
}
fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) {
if retained.parent_hash.is_none() {
retained.parent_hash = incoming.parent_hash;
}
if retained.timestamp.is_none() {
retained.timestamp = incoming.timestamp;
}
}
fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus {
let merged_block = |left: &BlockRef, right: &BlockRef| {
let mut block = *left;
enrich_block_ref(&mut block, right);
block
};
match (retained, incoming) {
(ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending,
(
ChainStatus::Preconfirmed { flashblock: left },
ChainStatus::Preconfirmed { flashblock: right },
) => {
debug_assert_eq!(left, right, "compatible pre-confirmed records agree");
ChainStatus::Preconfirmed {
flashblock: left.clone(),
}
}
(
ChainStatus::Reorged { dropped_from: left },
ChainStatus::Reorged {
dropped_from: right,
},
) => ChainStatus::Reorged {
dropped_from: merged_block(left, right),
},
(left, right) => {
let (left_block, left_rank, left_confirmations) = canonical_status_parts(left)
.expect("compatible duplicate has a canonical lifecycle");
let (right_block, right_rank, right_confirmations) = canonical_status_parts(right)
.expect("compatible duplicate has a canonical lifecycle");
let block = merged_block(left_block, right_block);
let rank = left_rank.max(right_rank);
match rank {
3 => ChainStatus::Finalized { block },
2 => ChainStatus::Safe { block },
_ => ChainStatus::Included {
block,
confirmations: left_confirmations.max(right_confirmations),
},
}
}
}
}
fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> {
match status {
ChainStatus::Included {
block,
confirmations,
} => Some((block, 1, *confirmations)),
ChainStatus::Safe { block } => Some((block, 2, 0)),
ChainStatus::Finalized { block } => Some((block, 3, 0)),
ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => {
None
}
}
}
fn input_source_rank(source: InputSource) -> u8 {
match source {
InputSource::Backfill => 0,
InputSource::Poll => 1,
InputSource::Subscription => 2,
InputSource::Flashblocks => 3,
InputSource::Batch => 4,
InputSource::Synthetic => 5,
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SubscriberDeliveryToken(Vec<u8>);
impl SubscriberDeliveryToken {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn into_bytes(self) -> Vec<u8> {
self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SubscriberCheckpoint(Vec<u8>);
impl SubscriberCheckpoint {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn into_bytes(self) -> Vec<u8> {
self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SubscriberPayloadCommitment(B256);
impl SubscriberPayloadCommitment {
pub const fn new(commitment: B256) -> Self {
Self(commitment)
}
pub const fn digest(&self) -> B256 {
self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct SubscriberResumePosition {
pub chain_id: u64,
pub coverage_head: BlockRef,
pub canonical_history: Vec<BlockRef>,
pub delivery_token: Option<SubscriberDeliveryToken>,
pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
}
impl SubscriberResumePosition {
pub fn new(
chain_id: u64,
coverage_head: BlockRef,
canonical_history: Vec<BlockRef>,
delivery_token: Option<SubscriberDeliveryToken>,
subscriber_checkpoint: Option<SubscriberCheckpoint>,
) -> Self {
Self {
chain_id,
coverage_head,
canonical_history,
delivery_token,
subscriber_checkpoint,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum DeliveryAudience {
#[default]
All,
Owners(Vec<HandlerId>),
AllExcept(Vec<HandlerId>),
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[non_exhaustive]
pub enum DeliveryScope {
#[default]
Canonical,
CanonicalProgress,
OwnerCatchup,
Preconfirmed,
}
impl DeliveryScope {
const fn advances_canonical_state(self) -> bool {
matches!(self, Self::Canonical | Self::CanonicalProgress)
}
}
#[derive(Clone, Debug)]
pub struct ReactiveInputDelivery<N: Network = Ethereum> {
record: ReactiveInputRecord<N>,
audience: DeliveryAudience,
scope: DeliveryScope,
}
impl<N: Network> ReactiveInputDelivery<N> {
pub fn new(
record: ReactiveInputRecord<N>,
audience: DeliveryAudience,
scope: DeliveryScope,
) -> Self {
Self {
record,
audience,
scope,
}
}
pub const fn record(&self) -> &ReactiveInputRecord<N> {
&self.record
}
pub const fn audience(&self) -> &DeliveryAudience {
&self.audience
}
pub const fn scope(&self) -> DeliveryScope {
self.scope
}
pub fn into_parts(self) -> (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope) {
(self.record, self.audience, self.scope)
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ReactiveInputBatchParts<N: Network = Ethereum> {
pub chain_id: Option<u64>,
pub deliveries: Vec<ReactiveInputDelivery<N>>,
pub delivery_token: Option<SubscriberDeliveryToken>,
pub subscriber_checkpoint: Option<SubscriberCheckpoint>,
pub payload_commitment: Option<SubscriberPayloadCommitment>,
pub chain_controls: Vec<ChainControl>,
}
#[derive(Clone, Debug)]
pub struct ReactiveInputBatch<N: Network = Ethereum> {
records: Vec<ReactiveInputRecord<N>>,
chain_id: Option<u64>,
delivery_token: Option<SubscriberDeliveryToken>,
subscriber_checkpoint: Option<SubscriberCheckpoint>,
payload_commitment: Option<SubscriberPayloadCommitment>,
audience: DeliveryAudience,
record_audiences: Option<Vec<DeliveryAudience>>,
delivery_scope: DeliveryScope,
record_delivery_scopes: Option<Vec<DeliveryScope>>,
chain_controls: Vec<ChainControl>,
}
type RuntimeInputDelivery<N> = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope);
impl<N: Network> ReactiveInputBatch<N> {
pub fn new(records: Vec<ReactiveInputRecord<N>>) -> Self {
let chain_id = common_record_chain_id(&records);
Self {
records,
chain_id,
delivery_token: None,
subscriber_checkpoint: None,
payload_commitment: None,
audience: DeliveryAudience::All,
record_audiences: None,
delivery_scope: DeliveryScope::Canonical,
record_delivery_scopes: None,
chain_controls: Vec::new(),
}
}
pub fn with_chain_id(mut self, chain_id: u64) -> Self {
self.chain_id = Some(chain_id);
self
}
pub const fn chain_id(&self) -> Option<u64> {
self.chain_id
}
pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self {
self.delivery_token = Some(token);
self
}
pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> {
self.delivery_token.as_ref()
}
pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self {
self.subscriber_checkpoint = Some(checkpoint);
self
}
pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> {
self.subscriber_checkpoint.as_ref()
}
pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self {
self.payload_commitment = Some(commitment);
self
}
pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> {
self.payload_commitment.as_ref()
}
pub fn with_audience(mut self, audience: DeliveryAudience) -> Self {
self.audience = audience;
self.record_audiences = None;
self
}
pub const fn audience(&self) -> &DeliveryAudience {
&self.audience
}
pub fn from_scoped_records(
records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience)>,
) -> Self {
let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip();
let chain_id = common_record_chain_id(&records);
Self {
records,
chain_id,
delivery_token: None,
subscriber_checkpoint: None,
payload_commitment: None,
audience: DeliveryAudience::All,
record_audiences: Some(record_audiences),
delivery_scope: DeliveryScope::Canonical,
record_delivery_scopes: None,
chain_controls: Vec::new(),
}
}
pub fn from_deliveries(deliveries: impl IntoIterator<Item = ReactiveInputDelivery<N>>) -> Self {
Self::from_scoped_records_with_delivery_scope(
deliveries
.into_iter()
.map(ReactiveInputDelivery::into_parts),
)
}
pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> {
if index >= self.records.len() {
return None;
}
Some(
self.record_audiences
.as_ref()
.and_then(|audiences| audiences.get(index))
.unwrap_or(&self.audience),
)
}
pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self {
self.delivery_scope = scope;
self.record_delivery_scopes = None;
self
}
pub fn record_delivery_scope(&self, index: usize) -> Option<DeliveryScope> {
if index >= self.records.len() {
return None;
}
Some(
self.record_delivery_scopes
.as_ref()
.and_then(|scopes| scopes.get(index))
.copied()
.unwrap_or(self.delivery_scope),
)
}
fn from_scoped_records_with_delivery_scope(
records: impl IntoIterator<Item = (ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
) -> Self {
let mut input_records = Vec::new();
let mut audiences = Vec::new();
let mut scopes = Vec::new();
for (record, audience, scope) in records {
input_records.push(record);
audiences.push(audience);
scopes.push(scope);
}
let chain_id = common_record_chain_id(&input_records);
Self {
records: input_records,
chain_id,
delivery_token: None,
subscriber_checkpoint: None,
payload_commitment: None,
audience: DeliveryAudience::All,
record_audiences: Some(audiences),
delivery_scope: DeliveryScope::Canonical,
record_delivery_scopes: Some(scopes),
chain_controls: Vec::new(),
}
}
pub fn with_chain_controls(mut self, controls: impl IntoIterator<Item = ChainControl>) -> Self {
self.chain_controls = controls.into_iter().collect();
self
}
pub fn chain_controls(&self) -> &[ChainControl] {
&self.chain_controls
}
pub fn records(&self) -> &[ReactiveInputRecord<N>] {
&self.records
}
pub fn into_records(self) -> Vec<ReactiveInputRecord<N>> {
self.records
}
pub fn into_parts(self) -> ReactiveInputBatchParts<N> {
let chain_id = self.chain_id;
let delivery_token = self.delivery_token;
let subscriber_checkpoint = self.subscriber_checkpoint;
let payload_commitment = self.payload_commitment;
let chain_controls = self.chain_controls;
let audiences = self
.record_audiences
.unwrap_or_else(|| vec![self.audience; self.records.len()]);
let scopes = self
.record_delivery_scopes
.unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
let deliveries = self
.records
.into_iter()
.zip(audiences)
.zip(scopes)
.map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope))
.collect();
ReactiveInputBatchParts {
chain_id,
deliveries,
delivery_token,
subscriber_checkpoint,
payload_commitment,
chain_controls,
}
}
fn into_runtime_parts(self) -> (Vec<RuntimeInputDelivery<N>>, Vec<ChainControl>, Option<u64>) {
let audiences = self
.record_audiences
.unwrap_or_else(|| vec![self.audience; self.records.len()]);
let scopes = self
.record_delivery_scopes
.unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]);
let records = self
.records
.into_iter()
.zip(audiences)
.zip(scopes)
.map(|((record, audience), scope)| (record, audience, scope))
.collect();
(records, self.chain_controls, self.chain_id)
}
fn take_delivery_token(&mut self) -> Option<SubscriberDeliveryToken> {
self.delivery_token.take()
}
fn take_subscriber_checkpoint(&mut self) -> Option<SubscriberCheckpoint> {
self.subscriber_checkpoint.take()
}
}
fn common_record_chain_id<N: Network>(records: &[ReactiveInputRecord<N>]) -> Option<u64> {
let chain_id = records.first()?.context.chain_id?;
records
.iter()
.all(|record| record.context.chain_id == Some(chain_id))
.then_some(chain_id)
}
pub trait ReactiveHandler<N: Network = Ethereum>: Send + Sync {
fn id(&self) -> HandlerId;
fn interests(&self) -> Vec<ReactiveInterest<N>>;
fn log_route_index(&self) -> Option<LogRouteIndex> {
None
}
fn handle(
&self,
ctx: &ReactiveContext,
input: &ReactiveInput<N>,
state: &dyn StateView,
) -> Result<HandlerOutcome, HandlerError>;
}
pub trait ReactiveHook<N: Network = Ethereum>: Send + Sync {
fn on_report(&self, report: Arc<ReactiveReport<N>>);
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone)]
pub enum ReactiveInterest<N: Network = Ethereum> {
Logs(LogInterest),
Blocks(BlockInterest),
PendingTransactions(PendingTxInterest<N>),
}
impl<N: Network> fmt::Debug for ReactiveInterest<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(),
Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(),
Self::PendingTransactions(interest) => f
.debug_tuple("PendingTransactions")
.field(interest)
.finish(),
}
}
}
#[derive(Clone)]
pub struct LogInterest {
pub provider_filter: Filter,
pub local_matcher: Option<Arc<dyn LogMatcher>>,
pub route_key: Option<RouteKeySpec>,
}
impl LogInterest {
pub fn matches(&self, log: &Log) -> bool {
self.provider_filter.rpc_matches(log)
&& self
.local_matcher
.as_ref()
.is_none_or(|matcher| matcher.matches(log))
}
pub fn route_key(&self, log: &Log) -> Option<RouteKey> {
self.route_key.as_ref().and_then(|spec| spec.extract(log))
}
}
impl fmt::Debug for LogInterest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogInterest")
.field("provider_filter", &self.provider_filter)
.field(
"local_matcher",
&self.local_matcher.as_ref().map(|_| "<matcher>"),
)
.field("route_key", &self.route_key)
.finish()
}
}
pub trait LogMatcher: Send + Sync {
fn matches(&self, log: &Log) -> bool;
}
#[derive(Clone)]
pub enum RouteKeySpec {
EmitterAddress,
Topic {
index: usize,
},
DataSlice {
offset: usize,
len: usize,
},
Custom(Arc<dyn RouteKeyExtractor>),
}
impl RouteKeySpec {
pub fn extract(&self, log: &Log) -> Option<RouteKey> {
match self {
Self::EmitterAddress => Some(RouteKey::Address(log.address())),
Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32),
Self::DataSlice { offset, len } => {
let data = log.inner.data.data.as_ref();
let end = offset.checked_add(*len)?;
data.get(*offset..end)
.map(|bytes| RouteKey::Bytes(bytes.to_vec()))
}
Self::Custom(extractor) => extractor.extract(log),
}
}
}
impl fmt::Debug for RouteKeySpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmitterAddress => f.write_str("EmitterAddress"),
Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(),
Self::DataSlice { offset, len } => f
.debug_struct("DataSlice")
.field("offset", offset)
.field("len", len)
.finish(),
Self::Custom(_) => f.write_str("Custom(<extractor>)"),
}
}
}
pub trait RouteKeyExtractor: Send + Sync {
fn extract(&self, log: &Log) -> Option<RouteKey>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum RouteKey {
Address(Address),
Bytes32(B256),
Bytes(Vec<u8>),
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum LogRouteKey {
Emitter(Address),
Topic {
index: usize,
value: B256,
},
DataSlice {
offset: usize,
value: Vec<u8>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogRouteIndex {
keys: Vec<LogRouteKey>,
}
impl LogRouteIndex {
pub fn new(primary: LogRouteKey, additional: impl IntoIterator<Item = LogRouteKey>) -> Self {
let mut keys = vec![primary];
for key in additional {
if !keys.contains(&key) {
keys.push(key);
}
}
Self { keys }
}
pub fn single(key: LogRouteKey) -> Self {
Self { keys: vec![key] }
}
pub fn keys(&self) -> &[LogRouteKey] {
&self.keys
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReactiveLogRoute {
pub handler_id: HandlerId,
pub route_key: Option<RouteKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BlockInterest {
pub mode: BlockInterestMode,
}
impl Default for BlockInterest {
fn default() -> Self {
Self {
mode: BlockInterestMode::Header,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BlockInterestMode {
Header,
FullBlock,
}
#[derive(Clone)]
pub struct PendingTxInterest<N: Network = Ethereum> {
pub full_transactions: bool,
pub from: AddressMatcher,
pub to: AddressMatcher,
pub selectors: SelectorMatcher,
pub local_matcher: Option<Arc<dyn PendingTxMatcher<N>>>,
}
impl<N: Network> Default for PendingTxInterest<N> {
fn default() -> Self {
Self {
full_transactions: false,
from: AddressMatcher::Any,
to: AddressMatcher::Any,
selectors: SelectorMatcher::Any,
local_matcher: None,
}
}
}
impl<N: Network> PendingTxInterest<N> {
fn matches_hash_only(&self) -> bool {
!self.full_transactions
&& self.from.is_any()
&& self.to.is_any()
&& self.selectors.is_any()
&& self.local_matcher.is_none()
}
fn matches_tx(&self, tx: &N::TransactionResponse) -> bool {
self.from.matches(tx.from())
&& self.to.matches_option(tx.to())
&& self.selectors.matches(tx.input())
&& self
.local_matcher
.as_ref()
.is_none_or(|matcher| matcher.matches(tx))
}
}
impl<N: Network> fmt::Debug for PendingTxInterest<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PendingTxInterest")
.field("full_transactions", &self.full_transactions)
.field("from", &self.from)
.field("to", &self.to)
.field("selectors", &self.selectors)
.field(
"local_matcher",
&self.local_matcher.as_ref().map(|_| "<matcher>"),
)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum AddressMatcher {
Any,
Exact(Address),
AnyOf(Vec<Address>),
}
impl AddressMatcher {
pub fn is_any(&self) -> bool {
matches!(self, Self::Any)
}
pub fn matches(&self, address: Address) -> bool {
match self {
Self::Any => true,
Self::Exact(expected) => *expected == address,
Self::AnyOf(addresses) => addresses.contains(&address),
}
}
pub fn matches_option(&self, address: Option<Address>) -> bool {
match (self, address) {
(Self::Any, _) => true,
(_, Some(address)) => self.matches(address),
_ => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SelectorMatcher {
Any,
AnyOf(Vec<[u8; 4]>),
}
impl SelectorMatcher {
pub fn is_any(&self) -> bool {
matches!(self, Self::Any)
}
pub fn matches(&self, input: &Bytes) -> bool {
match self {
Self::Any => true,
Self::AnyOf(selectors) => input
.get(..4)
.and_then(|bytes| bytes.try_into().ok())
.is_some_and(|selector| selectors.contains(&selector)),
}
}
}
pub trait PendingTxMatcher<N: Network = Ethereum>: Send + Sync {
fn matches(&self, tx: &N::TransactionResponse) -> bool;
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum TrackingPolicy {
Slots {
slots: Vec<U256>,
},
WholeAccount,
Scalars,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
struct TrackedRoot {
last_root: B256,
last_block: u64,
balance: U256,
nonce: u64,
code_hash: B256,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
pub struct ResyncId(String);
impl ResyncId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum ResyncReason {
HandlerRequested,
SkippedStateEffect,
MissedBlockRange,
RootMoved,
Custom(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ResyncBlock {
Latest,
Pending,
Safe,
Finalized,
Number(u64),
Hash {
number: u64,
hash: B256,
require_canonical: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize,
)]
pub struct AccountFieldMask {
pub balance: bool,
pub nonce: bool,
pub code: bool,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
)]
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, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum CacheHealth {
#[default]
Healthy,
Degraded {
since_block: u64,
},
Unhealthy {
since_block: u64,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[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),
}
}
fn restore(&self, snapshot: CacheMetricsSnapshot) {
self.deep_reorgs
.store(snapshot.deep_reorgs, Ordering::Relaxed);
self.reorgs_recovered
.store(snapshot.reorgs_recovered, Ordering::Relaxed);
self.resync_requests
.store(snapshot.resync_requests, Ordering::Relaxed);
self.resync_failures
.store(snapshot.resync_failures, Ordering::Relaxed);
self.missed_ranges
.store(snapshot.missed_ranges, Ordering::Relaxed);
self.coverage_gaps
.store(snapshot.coverage_gaps, Ordering::Relaxed);
self.pending_contamination
.store(snapshot.pending_contamination, Ordering::Relaxed);
self.stale_verdicts
.store(snapshot.stale_verdicts, 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>),
ChainControl(ChainControlReport),
MissedBlockRange(MissedRangeReport<N>),
Health(HealthReport<N>),
CoverageGap(CoverageGapReport<N>),
Error(ReactiveErrorReport<N>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChainControlReport {
pub control: ChainControl,
}
#[derive(Clone, Debug)]
pub struct InputReport<N: Network = Ethereum> {
pub input_ref: InputRef,
pub context: ReactiveContext,
pub provider: Option<ProviderRef>,
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,
Explicit,
}
#[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)]
#[non_exhaustive]
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("invalid reactive input record: {message}")]
InvalidInputRecord {
message: String,
},
#[error("invalid chain control: {message}")]
InvalidChainControl {
message: String,
},
#[error(
"owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
)]
OwnerCatchupOutsideJournal {
number: u64,
hash: B256,
},
#[error(transparent)]
Register(#[from] RegisterError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RegisterError {
#[error("handler id `{0}` is already registered")]
DuplicateHandler(HandlerId),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReactiveEngineRegisterError {
#[error(transparent)]
Register(#[from] RegisterError),
#[error(transparent)]
Subscriber(#[from] SubscriberError),
#[error(
"owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal"
)]
BackfillOutsideJournal {
start_block: u64,
end_block: Option<u64>,
retained_anchor: Option<BlockRef>,
},
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReactiveBaselineError {
#[error("cannot adopt a canonical baseline after reactive processing has started")]
ActiveRuntime,
#[error(
"canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})"
)]
ConflictingBaseline {
existing_number: u64,
existing_hash: B256,
requested_number: u64,
requested_hash: B256,
},
#[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")]
CacheChainMismatch {
baseline_chain_id: u64,
cache_chain_id: u64,
},
#[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")]
CacheBlockMismatch {
number: u64,
hash: B256,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReactiveEngineError {
#[error(transparent)]
Subscriber(#[from] SubscriberError),
#[error(transparent)]
Runtime(ReactiveError),
#[error(transparent)]
Baseline(#[from] ReactiveBaselineError),
#[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")]
Acknowledgement(#[source] SubscriberError),
#[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")]
Checkpoint(#[source] DurableCheckpointError),
#[error("cannot durably checkpoint reactive state before observing a canonical block")]
MissingCheckpointBlock,
#[error("pre-confirmed Flashblock batches cannot be durably checkpointed")]
PreconfirmationNotCheckpointable,
#[error("failed to encode durable reactive runtime state: {0}")]
RuntimeCheckpoint(String),
#[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")]
PendingCheckpointCommit,
#[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")]
PendingAcknowledgementCommit,
#[error(
"raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper"
)]
UncommittedDeliveryMetadata,
#[error(
"subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}"
)]
SubscriberChainMismatch {
subscriber_chain_id: u64,
cache_chain_id: u64,
},
#[error("subscriber does not advertise durable replay support")]
SubscriberNotDurable,
#[error(
"committed delivery token has no delivery witness; replay cannot be acknowledged safely"
)]
MissingReplayWitness,
#[error("replayed delivery token does not match its committed delivery witness")]
ReplayDeliveryMismatch,
#[error("failed to encode durable delivery witness: {0}")]
DeliveryWitness(String),
#[error(
"tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment"
)]
MissingPayloadCommitment,
#[error(
"cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})"
)]
PendingCheckpointCacheChanged {
staged_generation: u64,
current_generation: u64,
},
#[error(
"reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})"
)]
CheckpointReorgOutsideJournal {
common_ancestor: u64,
oldest_journaled: Option<u64>,
journal_depth: usize,
},
#[error(
"owner catch-up block {number} {hash} is outside the retained canonical rollback journal"
)]
OwnerCatchupOutsideJournal {
number: u64,
hash: B256,
},
}
impl From<ReactiveError> for ReactiveEngineError {
fn from(error: ReactiveError) -> Self {
match error {
ReactiveError::OwnerCatchupOutsideJournal { number, hash } => {
Self::OwnerCatchupOutsideJournal { number, hash }
}
error => Self::Runtime(error),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReactiveCheckpointRestoreError {
#[error("cannot restore a durable checkpoint into a runtime with canonical journal state")]
ActiveRuntime,
#[error("invalid durable reactive runtime state: {0}")]
InvalidRuntimeCheckpoint(String),
#[error(transparent)]
Checkpoint(#[from] DurableCheckpointError),
#[error("subscriber rejected durable resume position: {0}")]
Subscriber(#[source] SubscriberError),
#[error(
"subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}"
)]
SubscriberChainMismatch {
subscriber_chain_id: u64,
checkpoint_chain_id: u64,
},
#[error("subscriber does not advertise durable replay support")]
SubscriberNotDurable,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum CheckpointedIngest<N: Network = Ethereum> {
Applied(ReactiveBatchReport<N>),
ReplayAcknowledged,
}
#[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>>,
coverage_head: Option<BlockRef>,
pending_resyncs: Vec<ResyncRequest>,
health: CacheHealth,
safe_head: Option<BlockRef>,
finalized_head: Option<BlockRef>,
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>,
preconfirmed_branch: Option<PreconfirmedBranch>,
}
#[derive(Clone)]
struct PreconfirmedBranch {
flashblock: FlashblockRef,
canonical_cache: EvmCacheStateSnapshot,
}
#[derive(Clone, Debug)]
struct BlockJournal<N: Network = Ethereum> {
block: BlockRef,
inputs: Vec<InputRef>,
applied: Vec<AppliedReport<N>>,
handler_ids: Vec<HandlerId>,
resynced: Vec<ResyncReport>,
rollback_diffs: Vec<StateDiff>,
}
const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3;
#[derive(serde::Serialize, serde::Deserialize)]
struct DurableRuntimeCheckpoint {
version: u32,
safe_head: Option<BlockRef>,
finalized_head: Option<BlockRef>,
health: CacheHealth,
pending_resyncs: Vec<ResyncRequest>,
coverage_head: Option<BlockRef>,
journal: Vec<DurableBlockJournal>,
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>,
metrics: CacheMetricsSnapshot,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct DurableBlockJournal {
block: BlockRef,
handler_ids: Vec<HandlerId>,
rollback_diffs: Vec<StateDiff>,
}
struct DurableRuntimeRestorePlan {
checkpoint: Option<DurableRuntimeCheckpoint>,
fallback_history: Vec<BlockRef>,
}
impl DurableRuntimeRestorePlan {
fn canonical_history(&self) -> Vec<BlockRef> {
self.checkpoint.as_ref().map_or_else(
|| self.fallback_history.clone(),
|checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(),
)
}
}
#[derive(Clone)]
struct ReactiveRuntimeState<N: Network> {
journal: VecDeque<BlockJournal<N>>,
coverage_head: Option<BlockRef>,
pending_resyncs: Vec<ResyncRequest>,
health: CacheHealth,
safe_head: Option<BlockRef>,
finalized_head: Option<BlockRef>,
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>,
metrics: CacheMetricsSnapshot,
}
#[derive(Clone)]
struct ChainControlState {
journal_invalidated_from: Option<u64>,
resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>,
}
#[derive(Default)]
struct BatchDroppedCanonical {
identities: HashSet<(u64, B256)>,
implicit_spans: Vec<(u64, u64)>,
}
impl BatchDroppedCanonical {
fn covers_implicit_number(&self, number: u64) -> bool {
self.implicit_spans
.iter()
.any(|(from, through)| number >= *from && number <= *through)
}
fn contains(&self, block: &BlockRef) -> bool {
self.identities.contains(&(block.number, block.hash))
}
fn record_identity(&mut self, block: &BlockRef) {
self.identities.insert((block.number, block.hash));
}
fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) {
self.identities.insert((old_tip.number, old_tip.hash));
}
fn record_drained(&mut self, blocks: &[BlockRef]) {
let Some(from) = blocks.iter().map(|block| block.number).min() else {
return;
};
let through = blocks
.iter()
.map(|block| block.number)
.max()
.expect("a non-empty drained set has a maximum");
self.implicit_spans.push((from, through));
self.identities
.extend(blocks.iter().map(|block| (block.number, block.hash)));
}
}
pub struct ReactiveRegistry<N: Network = Ethereum> {
handlers: BTreeMap<u128, RegisteredHandler<N>>,
handler_positions: HashMap<HandlerId, u128>,
next_handler_position: u128,
indexed_log_handlers: HashMap<LogRouteKey, BTreeSet<u128>>,
fallback_log_handlers: BTreeSet<u128>,
data_slice_shapes: HashMap<(usize, usize), usize>,
}
struct RegisteredHandler<N: Network = Ethereum> {
id: HandlerId,
handler: Arc<dyn ReactiveHandler<N>>,
interests: Vec<ReactiveInterest<N>>,
has_log_interests: bool,
log_route_index: Option<LogRouteIndex>,
}
impl<N: Network> Default for ReactiveRegistry<N> {
fn default() -> Self {
Self::new()
}
}
impl<N: Network> ReactiveRegistry<N> {
pub fn new() -> Self {
Self {
handlers: BTreeMap::new(),
handler_positions: HashMap::new(),
next_handler_position: 0,
indexed_log_handlers: HashMap::new(),
fallback_log_handlers: BTreeSet::new(),
data_slice_shapes: HashMap::new(),
}
}
pub fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), RegisterError> {
let id = handler.id();
if self.handler_positions.contains_key(&id) {
return Err(RegisterError::DuplicateHandler(id));
}
let interests = handler.interests();
self.insert_handler_prepared(id, handler, interests);
Ok(())
}
fn insert_handler_prepared(
&mut self,
id: HandlerId,
handler: Arc<dyn ReactiveHandler<N>>,
interests: Vec<ReactiveInterest<N>>,
) {
debug_assert!(!self.handler_positions.contains_key(&id));
let has_log_interests = interests
.iter()
.any(|interest| matches!(interest, ReactiveInterest::Logs(_)));
let log_route_index = handler.log_route_index();
if self.next_handler_position == u128::MAX {
self.compact_handler_positions();
}
let position = self.next_handler_position;
self.next_handler_position += 1;
self.handler_positions.insert(id.clone(), position);
if let Some(index) = &log_route_index {
for key in index.keys() {
if let LogRouteKey::DataSlice { offset, value } = key {
*self
.data_slice_shapes
.entry((*offset, value.len()))
.or_default() += 1;
}
self.indexed_log_handlers
.entry(key.clone())
.or_default()
.insert(position);
}
} else if has_log_interests {
self.fallback_log_handlers.insert(position);
}
self.handlers.insert(
position,
RegisteredHandler {
id,
handler,
interests,
has_log_interests,
log_route_index,
},
);
}
pub fn unregister_handler(&mut self, id: &HandlerId) -> Option<Arc<dyn ReactiveHandler<N>>> {
let position = self.handler_positions.remove(id)?;
let registered = self.handlers.remove(&position)?;
if let Some(index) = ®istered.log_route_index {
for key in index.keys() {
let remove_bucket = self
.indexed_log_handlers
.get_mut(key)
.is_some_and(|owners| {
owners.remove(&position);
owners.is_empty()
});
if remove_bucket {
self.indexed_log_handlers.remove(key);
}
if let LogRouteKey::DataSlice { offset, value } = key {
let shape = (*offset, value.len());
let remove_shape =
self.data_slice_shapes.get_mut(&shape).is_some_and(|count| {
*count -= 1;
*count == 0
});
if remove_shape {
self.data_slice_shapes.remove(&shape);
}
}
}
} else {
self.fallback_log_handlers.remove(&position);
}
Some(registered.handler)
}
pub fn contains_handler(&self, id: &HandlerId) -> bool {
self.handler_positions.contains_key(id)
}
pub fn handler_ids(&self) -> Vec<HandlerId> {
self.handlers
.values()
.map(|handler| handler.id.clone())
.collect()
}
pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.handler_positions
.get(id)
.and_then(|position| self.handlers.get(position))
.map(|registered| registered.interests.as_slice())
}
pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
self.handlers
.values()
.flat_map(|handler| handler.interests.clone())
.collect()
}
pub fn log_subscription_filters(&self) -> Vec<Filter> {
let mut filters = Vec::new();
for interest in self.log_interests() {
merge_log_subscription_filter(&mut filters, &interest.provider_filter);
}
filters
}
pub fn route_log(&self, log: &Log) -> Vec<ReactiveLogRoute> {
self.log_handler_candidates(log)
.into_iter()
.filter_map(|handler| handler.route_log(log))
.collect()
}
fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler<N>> {
let mut indexed_positions = Vec::new();
if let Some(indexed) = self
.indexed_log_handlers
.get(&LogRouteKey::Emitter(log.address()))
{
indexed_positions.extend(indexed.iter().copied());
}
for (index, value) in log.topics().iter().copied().enumerate() {
if let Some(indexed) = self
.indexed_log_handlers
.get(&LogRouteKey::Topic { index, value })
{
indexed_positions.extend(indexed.iter().copied());
}
}
let data = log.inner.data.data.as_ref();
for &(offset, len) in self.data_slice_shapes.keys() {
let Some(end) = offset.checked_add(len) else {
continue;
};
let Some(value) = data.get(offset..end) else {
continue;
};
if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice {
offset,
value: value.to_vec(),
}) {
indexed_positions.extend(indexed.iter().copied());
}
}
if indexed_positions.is_empty() {
if self.fallback_log_handlers.is_empty() {
return Vec::new();
}
if !self.indexed_log_handlers.is_empty() {
return self
.fallback_log_handlers
.iter()
.filter_map(|position| self.handlers.get(position))
.collect();
}
return self
.handlers
.values()
.filter(|handler| handler.has_log_interests && handler.log_route_index.is_none())
.collect();
}
indexed_positions.extend(self.fallback_log_handlers.iter().copied());
indexed_positions.sort_unstable();
indexed_positions.dedup();
indexed_positions
.into_iter()
.filter_map(|position| self.handlers.get(&position))
.collect()
}
fn handlers(&self) -> impl Iterator<Item = &RegisteredHandler<N>> {
self.handlers.values()
}
fn log_interests(&self) -> impl Iterator<Item = &LogInterest> {
self.handlers.values().flat_map(|handler| {
handler
.interests
.iter()
.filter_map(|interest| match interest {
ReactiveInterest::Logs(interest) => Some(interest),
ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None,
})
})
}
fn compact_handler_positions(&mut self) {
let handlers = std::mem::take(&mut self.handlers);
self.handler_positions.clear();
self.indexed_log_handlers.clear();
self.fallback_log_handlers.clear();
self.data_slice_shapes.clear();
for (position, (_, handler)) in handlers.into_iter().enumerate() {
let position = position as u128;
self.handler_positions.insert(handler.id.clone(), position);
if let Some(index) = &handler.log_route_index {
for key in index.keys() {
if let LogRouteKey::DataSlice { offset, value } = key {
*self
.data_slice_shapes
.entry((*offset, value.len()))
.or_default() += 1;
}
self.indexed_log_handlers
.entry(key.clone())
.or_default()
.insert(position);
}
} else if handler.has_log_interests {
self.fallback_log_handlers.insert(position);
}
self.handlers.insert(position, handler);
}
self.next_handler_position = self.handlers.len() as u128;
}
}
impl<N: Network> ReactiveRuntime<N> {
pub fn new(config: ReactiveConfig) -> Self {
Self {
registry: ReactiveRegistry::new(),
hooks: Vec::new(),
config,
journal: VecDeque::new(),
coverage_head: None,
pending_resyncs: Vec::new(),
health: CacheHealth::Healthy,
safe_head: None,
finalized_head: None,
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(),
preconfirmed_branch: None,
}
}
fn checkpoint_state(&self) -> ReactiveRuntimeState<N> {
ReactiveRuntimeState {
journal: self.journal.clone(),
coverage_head: self.coverage_head,
pending_resyncs: self.pending_resyncs.clone(),
health: self.health,
safe_head: self.safe_head,
finalized_head: self.finalized_head,
freshness: self.freshness.clone(),
tracking: self.tracking.clone(),
tracked_roots: self.tracked_roots.clone(),
root_gate_cadence: self.root_gate_cadence,
last_gate_block: self.last_gate_block,
touched_since_gate: self.touched_since_gate.clone(),
metrics: self.metrics.snapshot(),
}
}
fn is_pristine_for_checkpoint_restore(&self) -> bool {
self.preconfirmed_branch.is_none()
&& self.journal.is_empty()
&& self.coverage_head.is_none()
&& self.pending_resyncs.is_empty()
&& self.health == CacheHealth::Healthy
&& self.safe_head.is_none()
&& self.finalized_head.is_none()
&& self.tracked_roots.is_empty()
&& self.last_gate_block.is_none()
&& self.touched_since_gate.is_empty()
&& self.metrics.snapshot() == CacheMetricsSnapshot::default()
}
fn adopted_baseline_only(&self) -> Option<BlockRef> {
let baseline = self.coverage_head?;
let journal_is_baseline_only = if self.config.journal_depth == 0 {
self.journal.is_empty()
} else {
self.journal.len() == 1
&& self.journal.front().is_some_and(|entry| {
entry.block == baseline
&& entry.inputs.is_empty()
&& entry.applied.is_empty()
&& entry.handler_ids.is_empty()
&& entry.resynced.is_empty()
&& entry.rollback_diffs.is_empty()
})
};
(self.preconfirmed_branch.is_none()
&& journal_is_baseline_only
&& self.pending_resyncs.is_empty()
&& self.health == CacheHealth::Healthy
&& self.safe_head.is_none()
&& self.finalized_head.is_none()
&& self.tracked_roots.is_empty()
&& self.last_gate_block.is_none()
&& self.touched_since_gate.is_empty()
&& self.metrics.snapshot() == CacheMetricsSnapshot::default())
.then_some(baseline)
}
fn restore_state(&mut self, state: ReactiveRuntimeState<N>) {
self.journal = state.journal;
self.coverage_head = state.coverage_head;
self.pending_resyncs = state.pending_resyncs;
self.health = state.health;
self.safe_head = state.safe_head;
self.finalized_head = state.finalized_head;
self.freshness = state.freshness;
self.tracking = state.tracking;
self.tracked_roots = state.tracked_roots;
self.root_gate_cadence = state.root_gate_cadence;
self.last_gate_block = state.last_gate_block;
self.touched_since_gate = state.touched_since_gate;
self.metrics.restore(state.metrics);
}
fn restore_transaction_state(&mut self, state: ReactiveRuntimeState<N>) {
let metrics = self.metrics.snapshot();
self.restore_state(state);
self.metrics.restore(metrics);
}
fn durable_checkpoint_bytes(&self) -> Result<Vec<u8>, ReactiveEngineError> {
let checkpoint = DurableRuntimeCheckpoint {
version: DURABLE_RUNTIME_CHECKPOINT_VERSION,
safe_head: self.safe_head,
finalized_head: self.finalized_head,
health: self.health,
pending_resyncs: self.pending_resyncs.clone(),
coverage_head: self.coverage_head,
journal: self
.journal
.iter()
.map(|entry| DurableBlockJournal {
block: entry.block,
handler_ids: entry.handler_ids.clone(),
rollback_diffs: entry.rollback_diffs.clone(),
})
.collect(),
freshness: self.freshness.clone(),
tracking: self.tracking.clone(),
tracked_roots: self.tracked_roots.clone(),
root_gate_cadence: self.root_gate_cadence,
last_gate_block: self.last_gate_block,
touched_since_gate: self.touched_since_gate.clone(),
metrics: self.metrics.snapshot(),
};
bincode::serialize(&checkpoint)
.map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string()))
}
fn plan_durable_checkpoint_restore(
&self,
bytes: &[u8],
expected_coverage: &BlockRef,
) -> Result<DurableRuntimeRestorePlan, ReactiveCheckpointRestoreError> {
let mut cursor = std::io::Cursor::new(bytes);
let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new()
.with_fixint_encoding()
.with_limit(bytes.len() as u64)
.deserialize_from(&mut cursor)
.map_err(|error| {
ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string())
})?;
if cursor.position() != bytes.len() as u64 {
return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
"runtime checkpoint has trailing bytes".to_owned(),
));
}
if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION {
return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(
format!(
"unsupported runtime checkpoint version {}",
checkpoint.version
),
));
}
self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?;
let retained = self.config.journal_depth.min(checkpoint.journal.len());
let discard = checkpoint.journal.len() - retained;
checkpoint.journal.drain(..discard);
Ok(DurableRuntimeRestorePlan {
checkpoint: Some(checkpoint),
fallback_history: Vec::new(),
})
}
fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) {
let Some(checkpoint) = plan.checkpoint else {
self.journal = plan
.fallback_history
.into_iter()
.map(|block| BlockJournal {
block,
inputs: Vec::new(),
applied: Vec::new(),
handler_ids: Vec::new(),
resynced: Vec::new(),
rollback_diffs: Vec::new(),
})
.collect();
return;
};
self.safe_head = checkpoint.safe_head;
self.finalized_head = checkpoint.finalized_head;
self.health = checkpoint.health;
self.pending_resyncs = checkpoint.pending_resyncs;
self.coverage_head = checkpoint.coverage_head;
self.journal = checkpoint
.journal
.into_iter()
.map(|entry| BlockJournal {
block: entry.block,
inputs: Vec::new(),
applied: Vec::new(),
handler_ids: entry.handler_ids,
resynced: Vec::new(),
rollback_diffs: entry.rollback_diffs,
})
.collect();
self.freshness = checkpoint.freshness;
self.tracking = checkpoint.tracking;
self.tracked_roots = checkpoint.tracked_roots;
self.root_gate_cadence = checkpoint.root_gate_cadence;
self.last_gate_block = checkpoint.last_gate_block;
self.touched_since_gate = checkpoint.touched_since_gate;
self.metrics.restore(checkpoint.metrics);
}
fn validate_durable_runtime_checkpoint(
&self,
checkpoint: &DurableRuntimeCheckpoint,
expected_coverage: &BlockRef,
) -> Result<(), ReactiveCheckpointRestoreError> {
let invalid =
|message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message);
let Some(coverage) = checkpoint.coverage_head.as_ref() else {
return Err(invalid(
"runtime checkpoint is missing its canonical coverage head".into(),
));
};
if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) {
return Err(invalid(format!(
"runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}",
coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash
)));
}
for (label, head) in [
("safe", checkpoint.safe_head.as_ref()),
("finalized", checkpoint.finalized_head.as_ref()),
] {
let Some(head) = head else { continue };
if head.number > coverage.number
|| (head.number == coverage.number && head.hash != coverage.hash)
{
return Err(invalid(format!(
"{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}",
head.number, head.hash, coverage.number, coverage.hash
)));
}
if head.number.checked_add(1) == Some(coverage.number)
&& coverage
.parent_hash
.is_some_and(|parent| parent != head.hash)
{
return Err(invalid(format!(
"canonical coverage does not descend from adjacent {label} head"
)));
}
}
if let (Some(finalized), Some(safe)) = (
checkpoint.finalized_head.as_ref(),
checkpoint.safe_head.as_ref(),
) {
if finalized.number > safe.number
|| (finalized.number == safe.number && finalized.hash != safe.hash)
{
return Err(invalid(
"finalized head is above or conflicts with the safe head".into(),
));
}
if finalized.number.checked_add(1) == Some(safe.number)
&& safe.parent_hash != Some(finalized.hash)
{
return Err(invalid(
"adjacent safe head does not descend from finalized head".into(),
));
}
}
let mut previous: Option<&DurableBlockJournal> = None;
for entry in &checkpoint.journal {
if entry.block.number > coverage.number
|| (entry.block.number == coverage.number && entry.block.hash != coverage.hash)
{
return Err(invalid(format!(
"journal block {}:{:?} lies beyond or conflicts with canonical coverage",
entry.block.number, entry.block.hash
)));
}
if let Some(previous) = previous {
if entry.block.number <= previous.block.number {
return Err(invalid(
"runtime journal block numbers are not strictly increasing".into(),
));
}
if previous.block.number.checked_add(1) == Some(entry.block.number)
&& entry.block.parent_hash.is_some()
&& entry.block.parent_hash != Some(previous.block.hash)
{
return Err(invalid(
"adjacent runtime journal blocks are not parent-linked".into(),
));
}
}
for (label, head) in [
("safe", checkpoint.safe_head.as_ref()),
("finalized", checkpoint.finalized_head.as_ref()),
] {
if let Some(head) = head
&& head.number == entry.block.number
&& !optional_block_refs_are_compatible(Some(head), Some(&entry.block))
{
return Err(invalid(format!(
"{label} head conflicts with the retained journal at block {}",
head.number
)));
}
}
let mut handler_ids = HashSet::new();
if entry
.handler_ids
.iter()
.any(|handler_id| !handler_ids.insert(handler_id))
{
return Err(invalid(
"runtime journal contains duplicate handler generation ids".into(),
));
}
previous = Some(entry);
}
if let Some(tail) = checkpoint.journal.last()
&& tail.block.number == coverage.number
&& !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage))
{
return Err(invalid(format!(
"runtime journal tail conflicts with canonical coverage at block {}",
coverage.number
)));
}
if let Some(tail) = checkpoint.journal.last()
&& tail.block.number.checked_add(1) == Some(coverage.number)
&& coverage
.parent_hash
.is_some_and(|parent_hash| parent_hash != tail.block.hash)
{
return Err(invalid(format!(
"canonical coverage does not descend from adjacent runtime journal tail at block {}",
tail.block.number
)));
}
if let Some(last_gate_block) = checkpoint.last_gate_block {
if last_gate_block > coverage.number {
return Err(invalid(
"root-gate cursor lies beyond canonical coverage".into(),
));
}
} else if !checkpoint.tracked_roots.is_empty() {
return Err(invalid(
"root-gate baselines exist without a completed gate cursor".into(),
));
}
for (address, baseline) in &checkpoint.tracked_roots {
let Some(policy) = checkpoint.tracking.get(address) else {
return Err(invalid(
"root-gate baseline has no corresponding tracking policy".into(),
));
};
if matches!(policy, TrackingPolicy::Slots { .. }) {
return Err(invalid(
"slot-only tracking cannot carry an account root baseline".into(),
));
}
if baseline.last_block > coverage.number
|| checkpoint
.last_gate_block
.is_some_and(|last_gate| baseline.last_block > last_gate)
{
return Err(invalid(
"root-gate baseline lies beyond the committed gate window".into(),
));
}
}
Ok(())
}
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.coverage_head
}
pub fn adopt_canonical_baseline(
&mut self,
baseline: BlockRef,
) -> Result<(), ReactiveBaselineError> {
self.validate_canonical_baseline_adoption(baseline)?;
if self.adopted_baseline_only().is_some() {
return Ok(());
}
self.coverage_head = Some(baseline);
if self.config.journal_depth > 0 {
self.journal.push_back(BlockJournal {
block: baseline,
inputs: Vec::new(),
applied: Vec::new(),
handler_ids: Vec::new(),
resynced: Vec::new(),
rollback_diffs: Vec::new(),
});
}
Ok(())
}
fn validate_canonical_baseline_adoption(
&self,
baseline: BlockRef,
) -> Result<(), ReactiveBaselineError> {
if let Some(existing) = self.adopted_baseline_only() {
return if existing == baseline {
Ok(())
} else {
Err(ReactiveBaselineError::ConflictingBaseline {
existing_number: existing.number,
existing_hash: existing.hash,
requested_number: baseline.number,
requested_hash: baseline.hash,
})
};
}
if !self.is_pristine_for_checkpoint_restore() {
return Err(ReactiveBaselineError::ActiveRuntime);
}
Ok(())
}
pub const fn safe_head(&self) -> Option<&BlockRef> {
self.safe_head.as_ref()
}
pub const fn finalized_head(&self) -> Option<&BlockRef> {
self.finalized_head.as_ref()
}
pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool {
self.journal
.iter()
.any(|entry| entry.handler_ids.contains(handler_id))
}
pub fn journaled_handler_ids(&self) -> HashSet<HandlerId> {
self.journal
.iter()
.flat_map(|entry| entry.handler_ids.iter().cloned())
.collect()
}
pub fn pending_resyncs(&self) -> &[ResyncRequest] {
&self.pending_resyncs
}
pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec<ResyncRequest> {
self.cancel_pending_resyncs_by_id(std::slice::from_ref(id))
}
pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec<ResyncRequest> {
if ids.is_empty() {
return Vec::new();
}
let ids: HashSet<&ResyncId> = ids.iter().collect();
let mut cancelled = Vec::new();
self.pending_resyncs.retain(|request| {
if ids.contains(&request.id) {
cancelled.push(request.clone());
false
} else {
true
}
});
cancelled
}
pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec<ResyncRequest> {
let mut cancelled = Vec::new();
self.pending_resyncs.retain_mut(|request| {
let (matching, remaining): (Vec<_>, Vec<_>) = request
.targets
.drain(..)
.partition(|target| resync_target_address(target) == address);
request.targets = remaining;
if !matching.is_empty() {
cancelled.push(ResyncRequest {
id: request.id.clone(),
reason: request.reason.clone(),
block: request.block.clone(),
targets: matching,
priority: request.priority,
});
}
!request.targets.is_empty()
});
cancelled
}
pub fn register_hook(&mut self, hook: Arc<dyn ReactiveHook<N>>) -> Result<(), RegisterError> {
self.hooks.push(hook);
Ok(())
}
pub fn interests(&self) -> Vec<ReactiveInterest<N>> {
self.registry.interests()
}
pub fn ingest_batch(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
let preconfirmation = batch_preconfirmation(&batch)?;
if let Some(flashblock) = preconfirmation.as_ref() {
self.prepare_preconfirmed_branch(cache, flashblock)?;
} else {
self.discard_preconfirmed_branch(cache);
}
let cache_state = EvmCacheStateSnapshot::capture(cache);
let runtime_state = self.checkpoint_state();
let batch_report = match self.ingest_batch_direct(cache, batch) {
Ok(report) => report,
Err(error) => {
cache_state.restore(cache);
self.restore_transaction_state(runtime_state);
return Err(error);
}
};
if let Some(flashblock) = preconfirmation {
self.restore_transaction_state(runtime_state);
if let Some(branch) = self.preconfirmed_branch.as_mut() {
branch.flashblock = flashblock;
}
}
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 preconfirmation = batch_preconfirmation(&batch)?;
if let Some(flashblock) = preconfirmation.as_ref() {
self.prepare_preconfirmed_branch(cache, flashblock)?;
} else {
self.discard_preconfirmed_branch(cache);
}
let cache_state = EvmCacheStateSnapshot::capture(cache);
let runtime_state = self.checkpoint_state();
let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) {
Ok(report) => report,
Err(error) => {
cache_state.restore(cache);
self.restore_transaction_state(runtime_state);
return Err(error);
}
};
if let Some(flashblock) = preconfirmation {
self.restore_transaction_state(runtime_state);
if let Some(branch) = self.preconfirmed_branch.as_mut() {
branch.flashblock = flashblock;
}
}
self.dispatch_reports(&batch_report.reports);
let _ = &self.config;
Ok(batch_report)
}
pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> {
self.preconfirmed_branch
.as_ref()
.map(|branch| &branch.flashblock)
}
pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) {
self.discard_preconfirmed_branch(cache);
}
fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) {
if let Some(branch) = self.preconfirmed_branch.take() {
branch.canonical_cache.restore(cache);
}
}
fn prepare_preconfirmed_branch(
&mut self,
cache: &mut EvmCache,
incoming: &FlashblockRef,
) -> Result<(), ReactiveError> {
let Some(canonical) = self.coverage_head else {
self.discard_preconfirmed_branch(cache);
return Err(ReactiveError::InvalidInputRecord {
message: "pre-confirmed state requires an exact canonical coverage baseline".into(),
});
};
if canonical.number.checked_add(1) != Some(incoming.block_number) {
self.discard_preconfirmed_branch(cache);
return Err(ReactiveError::InvalidInputRecord {
message: format!(
"pre-confirmed block {} is not the exact successor of canonical block {}",
incoming.block_number, canonical.number
),
});
}
if incoming.parent_hash != Some(canonical.hash) {
self.discard_preconfirmed_branch(cache);
return Err(ReactiveError::InvalidInputRecord {
message: "pre-confirmed block parent does not match the canonical coverage hash"
.into(),
});
}
if let Some(active) = self.preconfirmed_branch.as_ref()
&& active.flashblock.same_payload(incoming)
{
if let (Some(active_index), Some(incoming_index)) =
(active.flashblock.index, incoming.index)
&& incoming_index < active_index
{
return Err(ReactiveError::InvalidInputRecord {
message: format!(
"Flashblock index regressed from {active_index} to {incoming_index}"
),
});
}
if active.flashblock.index.is_some()
&& active.flashblock.index == incoming.index
&& active.flashblock.content_hash != incoming.content_hash
{
self.discard_preconfirmed_branch(cache);
return Err(ReactiveError::InvalidInputRecord {
message: "same Flashblock payload/index carried conflicting cumulative content"
.into(),
});
}
install_preconfirmed_cache_context(cache, incoming);
return Ok(());
}
self.discard_preconfirmed_branch(cache);
self.preconfirmed_branch = Some(PreconfirmedBranch {
flashblock: incoming.clone(),
canonical_cache: EvmCacheStateSnapshot::capture(cache),
});
install_preconfirmed_cache_context(cache, incoming);
Ok(())
}
fn ingest_batch_with_resync_direct(
&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)));
}
Ok(batch_report)
}
fn ingest_batch_direct(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveError> {
let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts();
if let Some(chain_id) = batch_chain_id
&& chain_id != cache.chain_id()
{
return Err(ReactiveError::InvalidInputRecord {
message: format!(
"batch chain id {chain_id} does not match cache chain id {}",
cache.chain_id()
),
});
}
if !chain_controls.is_empty() && batch_chain_id.is_none() {
return Err(ReactiveError::InvalidChainControl {
message: "chain-control batches require an authoritative batch chain id".into(),
});
}
for (record, _, _) in &records {
record.validated_identity()?;
if let Some(chain_id) = record.context.chain_id
&& chain_id != cache.chain_id()
{
return Err(ReactiveError::InvalidInputRecord {
message: format!(
"input chain id {chain_id} does not match cache chain id {}",
cache.chain_id()
),
});
}
}
let records = sort_scoped_records(dedupe_scoped_records(records)?);
let mut batch_report = ReactiveBatchReport::default();
let mut reports_to_dispatch = Vec::new();
let control_split = validate_control_phase_order(&chain_controls)?;
let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split);
let pre_record_state =
self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?;
self.validate_owner_catchup_against_journal(&pre_record_state, &records)?;
let mut batch_dropped = BatchDroppedCanonical::default();
for control in pre_record_controls {
if let ChainControl::Reorg {
common_ancestor,
old_tip,
..
} = control
{
batch_dropped.record_explicit(common_ancestor, old_tip);
let drained = self
.journal
.iter()
.filter(|entry| entry.block.number > common_ancestor.number)
.map(|entry| entry.block)
.collect::<Vec<_>>();
batch_dropped.record_drained(&drained);
}
}
let certified_progress_through = post_record_controls
.iter()
.filter_map(canonical_coverage_control_block)
.map(|block| block.number)
.max();
for control in pre_record_controls.iter().cloned() {
self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
}
let mut touched_addrs: HashSet<Address> = HashSet::new();
let mut canonical_batch_block: Option<u64> = None;
for (record, audience, delivery_scope) in records {
let raw_canonical_block = canonical_record_block(&record).copied();
let canonical_block = raw_canonical_block.map(|block| {
pre_record_state
.resolved_canonical_blocks
.get(&(block.number, block.hash))
.copied()
.unwrap_or(block)
});
let input_ref = record.input_ref();
reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport {
input_ref,
context: record.context.clone(),
provider: record.provider.clone(),
_network: PhantomData,
})));
let recovered_reorg = if delivery_scope.advances_canonical_state() {
if let Some(block) = canonical_block.as_ref() {
let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress
&& certified_progress_through
.is_some_and(|through| block.number <= through);
let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| {
raw.parent_hash.is_none()
&& batch_dropped.covers_implicit_number(raw.number)
});
self.recover_for_canonical_input(
cache,
block,
gap_is_certified,
parentless_replacement_is_proven,
&mut reports_to_dispatch,
)
} else {
None
}
} else {
None
};
let recovered_reorg_for_input = recovered_reorg.is_some();
if let Some(reorg_report) = recovered_reorg {
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 reorg_signal_block(&record).is_some() {
if delivery_scope.advances_canonical_state()
&& let Some(reorg_report) = self.recover_for_reorged_input(
cache,
&record,
&mut batch_dropped,
&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 delivery_scope == DeliveryScope::OwnerCatchup {
self.validate_owner_catchup_record_against_current_journal(&record)?;
}
if delivery_scope.advances_canonical_state()
&& let Some(block) = canonical_block.as_ref()
{
canonical_batch_block = Some(block.number);
self.record_journal_input(block, input_ref);
}
if delivery_scope.advances_canonical_state()
&& let Some(block) = canonical_block.as_ref()
{
match advance_block_for_canonical_record(cache, &record) {
Some(Ok(())) => {
cache.advance_compact_block(block.number, block.hash, block.timestamp, true)
}
Some(Err(err)) => {
cache.advance_compact_block(
block.number,
block.hash,
block.timestamp,
false,
);
reports_to_dispatch.push(Arc::new(ReactiveReport::Error(
ReactiveErrorReport {
input_ref: Some(input_ref),
message: err.to_string(),
_network: PhantomData,
},
)));
}
None => cache.advance_compact_block(
block.number,
block.hash,
block.timestamp,
!recovered_reorg_for_input,
),
}
}
let executions = self.execute_handlers(cache, &record, input_ref, &audience)?;
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 = delivery_scope
.advances_canonical_state()
.then_some(canonical_block)
.flatten()
.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);
}
}
if delivery_scope.advances_canonical_state() {
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_block.as_ref() {
if delivery_scope.advances_canonical_state() {
self.record_journal_applied(block, applied.clone());
} else {
self.record_journal_applied_if_present(block, applied.clone());
}
}
batch_report.applied.push(applied);
}
}
for control in post_record_controls.iter().cloned() {
if let Some(block) = canonical_coverage_control_block(&control) {
canonical_batch_block = Some(
canonical_batch_block.map_or(block.number, |current| current.max(block.number)),
);
}
self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch);
}
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 validate_owner_catchup_against_journal(
&self,
control_state: &ChainControlState,
records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
) -> Result<(), ReactiveError> {
for (record, _, delivery_scope) in records {
if *delivery_scope != DeliveryScope::OwnerCatchup {
continue;
}
if reorg_signal_block(record).is_some() {
continue;
}
let context_block = canonical_record_block(record).ok_or_else(|| {
ReactiveError::InvalidChainControl {
message: "owner catch-up input has no canonical block identity".into(),
}
})?;
let block = resolve_record_block_payload_metadata(record, *context_block)?;
let invalidated_by_control = control_state
.journal_invalidated_from
.is_some_and(|from| block.number >= from);
let rollbackable = !invalidated_by_control
&& self.journal.iter().any(|entry| {
optional_block_refs_are_compatible(Some(&entry.block), Some(&block))
});
if !rollbackable {
return Err(ReactiveError::OwnerCatchupOutsideJournal {
number: block.number,
hash: block.hash,
});
}
}
Ok(())
}
fn validate_owner_catchup_record_against_current_journal(
&self,
record: &ReactiveInputRecord<N>,
) -> Result<(), ReactiveError> {
let context_block =
canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl {
message: "owner catch-up input has no canonical block identity".into(),
})?;
let block = resolve_record_block_payload_metadata(record, *context_block)?;
if self
.journal
.iter()
.any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block)))
{
return Ok(());
}
Err(ReactiveError::OwnerCatchupOutsideJournal {
number: block.number,
hash: block.hash,
})
}
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,
audience: &DeliveryAudience,
) -> Result<Vec<HandlerExecution>, ReactiveError> {
let mut executions = Vec::new();
let candidates: Vec<_> = match &record.input {
ReactiveInput::Log(log) => self.registry.log_handler_candidates(log),
ReactiveInput::BlockHeader(_)
| ReactiveInput::FullBlock(_)
| ReactiveInput::PendingTxHash(_)
| ReactiveInput::PendingTx(_) => self.registry.handlers().collect(),
};
for registered in candidates {
match audience {
DeliveryAudience::Owners(owners) if !owners.contains(®istered.id) => continue,
DeliveryAudience::AllExcept(excluded) if excluded.contains(®istered.id) => {
continue;
}
DeliveryAudience::All
| DeliveryAudience::Owners(_)
| DeliveryAudience::AllExcept(_) => {}
}
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,
matches!(
record.context.chain_status,
ChainStatus::Preconfirmed { .. }
),
));
}
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 apply_chain_control(
&mut self,
cache: &mut EvmCache,
control: ChainControl,
batch_report: &mut ReactiveBatchReport<N>,
reports: &mut Vec<Arc<ReactiveReport<N>>>,
) {
match &control {
ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block),
ChainControl::Finalized(block) => {
set_or_enrich_block_ref(&mut self.finalized_head, block);
}
ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => {
let preserve_env = self.coverage_head.as_ref().is_some_and(|current| {
optional_block_refs_are_compatible(Some(current), Some(block))
});
cache.advance_compact_block(
block.number,
block.hash,
block.timestamp,
preserve_env,
);
advance_or_enrich_coverage(&mut self.coverage_head, block);
let enriched = self.journal_entry_mut(block).block;
advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
self.trim_journal();
}
ChainControl::Barrier { block: None, .. } => {}
ChainControl::Reorg {
common_ancestor,
old_tip,
..
} => {
cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1));
self.rebase_validation_state_from(common_ancestor.number.saturating_add(1));
let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| {
entry.block.number == common_ancestor.number
&& entry.block.hash == common_ancestor.hash
}) {
self.drain_journal_after(ancestor_index)
} else {
if self
.journal
.front()
.is_none_or(|entry| entry.block.number > common_ancestor.number)
{
reports.extend(
self.warn_under_recovery(common_ancestor.number.saturating_add(1)),
);
}
self.drain_journal_from_number(common_ancestor.number.saturating_add(1))
};
let reorg_report = self
.recover_dropped_journals(cache, dropped, ReorgReason::Explicit)
.unwrap_or_else(|| ReorgReport {
dropped: Some(*old_tip),
dropped_blocks: Vec::new(),
dropped_inputs: Vec::new(),
rollback_updates: Vec::new(),
rollback_diff: StateDiff::default(),
purge_updates: Vec::new(),
purge_diff: StateDiff::default(),
canceled_resyncs: self
.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)),
reason: ReorgReason::Explicit,
_network: PhantomData,
});
remove_canceled_resyncs_from_batch(
&mut batch_report.resyncs,
&reorg_report.canceled_resyncs,
);
self.metrics
.reorgs_recovered
.fetch_add(1, Ordering::Relaxed);
reports.push(Arc::new(ReactiveReport::Reorg(reorg_report)));
if self.safe_head.as_ref().is_some_and(|head| {
head.number > common_ancestor.number
|| (head.number == common_ancestor.number
&& head.hash != common_ancestor.hash)
}) {
self.safe_head = None;
}
if self.finalized_head.as_ref().is_some_and(|head| {
head.number > common_ancestor.number
|| (head.number == common_ancestor.number
&& head.hash != common_ancestor.hash)
}) {
self.finalized_head = None;
}
let mut enriched_ancestor = *common_ancestor;
if let Some(entry) = self.journal.iter().find(|entry| {
entry.block.number == common_ancestor.number
&& entry.block.hash == common_ancestor.hash
}) {
enrich_block_ref(&mut enriched_ancestor, &entry.block);
}
if let Some(current) = self.coverage_head.as_ref()
&& current.number == common_ancestor.number
&& current.hash == common_ancestor.hash
{
enrich_block_ref(&mut enriched_ancestor, current);
}
self.coverage_head = Some(enriched_ancestor);
cache.advance_compact_block(
enriched_ancestor.number,
enriched_ancestor.hash,
enriched_ancestor.timestamp,
false,
);
let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block;
self.coverage_head = Some(enriched_ancestor);
self.trim_journal();
}
}
reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport {
control,
})));
}
fn validate_ingest_sequence(
&self,
pre_record_controls: &[ChainControl],
post_record_controls: &[ChainControl],
records: &[(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)],
) -> Result<ChainControlState, ReactiveError> {
let mut controls =
Vec::with_capacity(pre_record_controls.len() + post_record_controls.len());
controls.extend_from_slice(pre_record_controls);
controls.extend_from_slice(post_record_controls);
let state = CanonicalSequenceState::new(
self.journal.iter().map(|entry| entry.block).collect(),
self.coverage_head,
self.safe_head,
self.finalized_head,
);
let record_metadata = records
.iter()
.map(|(record, _, scope)| (record, *scope))
.collect::<Vec<_>>();
let validation = validate_canonical_sequence_parts(
&state,
&controls,
&record_metadata,
CanonicalSequenceValidationPolicy::ObserveIncompleteRollback,
)
.map_err(CanonicalSequenceError::into_reactive_error)?;
let mut resolved_canonical_blocks = HashMap::new();
for mutation in validation.mutations() {
if let CanonicalSequenceMutation::Canonical(block) = mutation {
resolved_canonical_blocks
.entry((block.number, block.hash))
.and_modify(|known| enrich_block_ref(known, block))
.or_insert(*block);
}
}
Ok(ChainControlState {
journal_invalidated_from: pre_record_controls
.iter()
.filter_map(|control| match control {
ChainControl::Reorg {
common_ancestor, ..
} => Some(common_ancestor.number.saturating_add(1)),
_ => None,
})
.min(),
resolved_canonical_blocks,
})
}
fn recover_for_canonical_input(
&mut self,
cache: &mut EvmCache,
block: &BlockRef,
gap_is_certified: bool,
parentless_replacement_is_proven: bool,
health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
) -> Option<ReorgReport<N>> {
let latest = self
.coverage_head
.or_else(|| self.journal.back().map(|entry| entry.block))?;
if latest.number == block.number && latest.hash == block.hash {
return None;
}
if self
.journal
.iter()
.any(|entry| entry.block.hash == block.hash && entry.block.number == block.number)
{
return None;
}
if latest.number.checked_add(1) == Some(block.number)
&& (block.parent_hash == Some(latest.hash)
|| (parentless_replacement_is_proven && block.parent_hash.is_none()))
{
return None;
}
if latest
.number
.checked_add(1)
.is_some_and(|next| block.number > next)
{
if !gap_is_certified {
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, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash {
if let Some(parent_index) = self.journal.iter().rposition(|entry| {
entry.block.number.checked_add(1) == Some(block.number)
&& entry.block.hash == parent_hash
}) {
let parent = self.journal[parent_index].block;
cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1));
(self.drain_journal_after(parent_index), Some(parent))
} else {
let proven_finalized_anchor = self.finalized_head.filter(|finalized| {
finalized.number.checked_add(1) == Some(block.number)
&& parent_hash == finalized.hash
});
let invalidated_from = proven_finalized_anchor
.map_or(0, |finalized| finalized.number.saturating_add(1));
cache.invalidate_cached_block_hashes_from(invalidated_from);
if block.number > 0 {
cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash);
}
health_reports.extend(self.warn_under_recovery(block.number));
let dropped = if let Some(finalized) = proven_finalized_anchor {
self.drain_journal_from_number(finalized.number.saturating_add(1))
} else {
self.drain_journal_from_number(0)
};
(dropped, proven_finalized_anchor)
}
} else {
cache.invalidate_cached_block_hashes_from(0);
health_reports.extend(self.warn_under_recovery(block.number));
(self.drain_journal_from_number(0), None)
};
self.rebase_validation_state_from(
authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)),
);
let report = self
.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch)
.or_else(|| {
Some(ReorgReport {
dropped: Some(latest),
dropped_blocks: Vec::new(),
dropped_inputs: Vec::new(),
rollback_updates: Vec::new(),
rollback_diff: StateDiff::default(),
purge_updates: Vec::new(),
purge_diff: StateDiff::default(),
canceled_resyncs: self
.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)),
reason: ReorgReason::ParentMismatch,
_network: PhantomData,
})
});
self.coverage_head = authenticated_anchor;
for head in [&mut self.safe_head, &mut self.finalized_head] {
if head.is_some_and(|head| {
authenticated_anchor.is_none_or(|anchor| {
head.number > anchor.number
|| (head.number == anchor.number && head.hash != anchor.hash)
})
}) {
*head = None;
}
}
if let Some(anchor) = authenticated_anchor {
cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false);
}
report
}
fn recover_for_reorged_input(
&mut self,
cache: &mut EvmCache,
record: &ReactiveInputRecord<N>,
batch_dropped: &mut BatchDroppedCanonical,
health_reports: &mut Vec<Arc<ReactiveReport<N>>>,
) -> Option<ReorgReport<N>> {
let (incoming_dropped_block, reason) = reorg_signal_block(record)?;
if batch_dropped.contains(&incoming_dropped_block) {
let canceled_resyncs = self
.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block));
return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
dropped: Some(incoming_dropped_block),
dropped_blocks: vec![incoming_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,
});
}
let exact_index = self.journal.iter().position(|entry| {
entry.block.number == incoming_dropped_block.number
&& entry.block.hash == incoming_dropped_block.hash
});
let mut dropped_block = exact_index
.map(|index| self.journal[index].block)
.or_else(|| {
self.coverage_head.filter(|known| {
known.number == incoming_dropped_block.number
&& known.hash == incoming_dropped_block.hash
})
})
.unwrap_or(incoming_dropped_block);
enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
let replacement_is_known = exact_index.is_none()
&& (self.journal.iter().any(|entry| {
entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash
}) || self.coverage_head.is_some_and(|head| {
head.number == dropped_block.number && head.hash != dropped_block.hash
}));
if replacement_is_known {
let canceled_resyncs =
self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
return (!canceled_resyncs.is_empty()).then(|| ReorgReport {
dropped: Some(dropped_block),
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,
});
}
let authenticated_anchor = exact_index.and_then(|index| {
let ancestor_number = dropped_block.number.checked_sub(1)?;
let retained = self
.journal
.iter()
.take(index)
.rev()
.find(|entry| entry.block.number == ancestor_number)
.map(|entry| entry.block);
let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef {
number: ancestor_number,
hash,
parent_hash: None,
timestamp: None,
});
let finalized_fallback = self
.finalized_head
.filter(|head| head.number == ancestor_number);
let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?;
for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()]
.into_iter()
.flatten()
{
if head.number == anchor.number && head.hash == anchor.hash {
enrich_block_ref(&mut anchor, head);
}
}
Some(anchor)
});
cache.invalidate_cached_block_hashes_from(dropped_block.number);
let dropped = if let Some(index) = exact_index {
self.drain_journal_from(index)
} else {
health_reports.extend(self.warn_under_recovery(dropped_block.number));
self.drain_journal_from_number(dropped_block.number)
};
let drained_blocks = dropped.iter().map(|entry| entry.block).collect::<Vec<_>>();
batch_dropped.record_drained(&drained_blocks);
batch_dropped.record_identity(&dropped_block);
self.rebase_validation_state_from(dropped_block.number);
let recovered_journal = !dropped.is_empty();
let report = if !recovered_journal {
let canceled_resyncs =
self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block));
Some(ReorgReport {
dropped: Some(dropped_block),
dropped_blocks: Vec::new(),
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,
})
} else {
self.recover_dropped_journals(cache, dropped, reason)
};
if recovered_journal {
if let Some(anchor) = authenticated_anchor {
self.coverage_head = Some(anchor);
}
let coverage = self.coverage_head;
for head in [&mut self.safe_head, &mut self.finalized_head] {
if head.is_some_and(|head| {
coverage.is_none_or(|coverage| {
head.number > coverage.number
|| (head.number == coverage.number && head.hash != coverage.hash)
})
}) {
*head = None;
}
}
}
if recovered_journal
&& report.is_some()
&& let Some(head) = self.coverage_head
{
cache.advance_compact_block(head.number, head.hash, head.timestamp, false);
}
report
}
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) {
advance_or_enrich_coverage(&mut self.coverage_head, block);
let entry = self.journal_entry_mut(block);
let enriched = entry.block;
if !entry.inputs.contains(&input_ref) {
entry.inputs.push(input_ref);
}
advance_or_enrich_coverage(&mut self.coverage_head, &enriched);
self.trim_journal();
}
fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
let entry = self.journal_entry_mut(block);
if !entry.handler_ids.contains(&applied.handler_id) {
entry.handler_ids.push(applied.handler_id.clone());
}
entry.rollback_diffs.push(applied.diff.clone());
entry.applied.push(applied);
self.trim_journal();
}
fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport<N>) {
let Some(entry) = self
.journal
.iter_mut()
.find(|entry| entry.block.number == block.number && entry.block.hash == block.hash)
else {
return;
};
if !entry.handler_ids.contains(&applied.handler_id) {
entry.handler_ids.push(applied.handler_id.clone());
}
entry.rollback_diffs.push(applied.diff.clone());
entry.applied.push(applied);
}
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;
};
let entry = self.journal_entry_mut(&block);
entry.rollback_diffs.push(report.diff.clone());
entry.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)
{
enrich_block_ref(&mut self.journal[index].block, block);
return &mut self.journal[index];
}
self.journal.push_back(BlockJournal {
block: *block,
inputs: Vec::new(),
applied: Vec::new(),
handler_ids: Vec::new(),
resynced: Vec::new(),
rollback_diffs: 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 first_dropped_block = dropped
.iter()
.map(|entry| entry.block.number)
.min()
.expect("non-empty dropped journal set");
self.rebase_validation_state_from(first_dropped_block);
if self
.safe_head
.is_some_and(|head| head.number >= first_dropped_block)
{
self.safe_head = None;
}
let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).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)
};
self.coverage_head = self.journal.back().map(|entry| entry.block);
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 rebase_validation_state_from(&mut self, first_dropped_block: u64) {
if let Some(freshness) = self.freshness.as_mut() {
freshness.invalidate_valid_through_from(first_dropped_block);
}
self.tracked_roots
.retain(|_, baseline| baseline.last_block < first_dropped_block);
if self
.last_gate_block
.is_some_and(|block| block >= first_dropped_block)
{
self.last_gate_block = self
.tracked_roots
.values()
.map(|baseline| baseline.last_block)
.max();
}
self.touched_since_gate.clear();
}
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 install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) {
cache.set_block(BlockId::pending());
cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas);
cache.set_coinbase(flashblock.beneficiary);
cache.set_prevrandao(flashblock.prevrandao);
cache.set_block_gas_limit(flashblock.gas_limit);
cache.set_timestamp(flashblock.timestamp);
}
pub fn validate_canonical_sequence<N: Network>(
state: &CanonicalSequenceState,
batch: &ReactiveInputBatch<N>,
) -> Result<CanonicalSequenceValidation, ReactiveError> {
validate_canonical_sequence_diagnostic(state, batch)
.map_err(CanonicalSequenceError::into_reactive_error)
}
pub fn validate_canonical_sequence_diagnostic<N: Network>(
state: &CanonicalSequenceState,
batch: &ReactiveInputBatch<N>,
) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
validate_canonical_sequence_internal(
state,
batch,
CanonicalSequenceValidationPolicy::RequireCompleteRollback,
)
}
pub fn normalize_and_validate_canonical_sequence<N: Network>(
state: &CanonicalSequenceState,
batch: &ReactiveInputBatch<N>,
) -> Result<CanonicalSequenceValidation, ReactiveError> {
normalize_and_validate_canonical_sequence_diagnostic(state, batch)
.map_err(CanonicalSequenceError::into_reactive_error)
}
pub fn normalize_and_validate_canonical_sequence_diagnostic<N: Network>(
state: &CanonicalSequenceState,
batch: &ReactiveInputBatch<N>,
) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
validate_canonical_sequence_internal(
state,
batch,
CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage,
)
}
fn validate_canonical_sequence_internal<N: Network>(
state: &CanonicalSequenceState,
batch: &ReactiveInputBatch<N>,
policy: CanonicalSequenceValidationPolicy,
) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
let records = batch
.records()
.iter()
.enumerate()
.map(|(index, record)| {
(
record.clone(),
DeliveryAudience::All,
batch
.record_delivery_scope(index)
.expect("enumerated record always has a delivery scope"),
)
})
.collect::<Vec<_>>();
let records = sort_scoped_records(dedupe_scoped_records(records)?);
let records = records
.iter()
.map(|(record, _, scope)| (record, *scope))
.collect::<Vec<_>>();
validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy)
}
#[derive(Clone, Copy)]
enum CanonicalSequenceValidationPolicy {
RequireCompleteRollback,
RequireCompleteRollbackNormalizeCoverage,
ObserveIncompleteRollback,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CanonicalRollbackKind {
Explicit,
Removed,
ImplicitParent,
MissingReplacement,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CanonicalSequenceError {
#[error(transparent)]
Invalid(#[from] ReactiveError),
#[error(
"{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
)]
IncompleteRollback {
common_ancestor: u64,
oldest_retained: Option<u64>,
kind: CanonicalRollbackKind,
},
}
#[derive(Clone, Copy, Debug)]
struct RequiredReorgAnchor {
number: u64,
block: Option<BlockRef>,
permits_missing_child_parent: bool,
must_be_consumed: bool,
}
#[derive(Debug)]
struct SequenceRewind {
common_ancestor: Option<BlockRef>,
dropped: Vec<BlockRef>,
}
impl RequiredReorgAnchor {
const fn hash(self) -> Option<B256> {
match self.block {
Some(block) => Some(block.hash),
None => None,
}
}
}
impl CanonicalSequenceError {
pub const fn requires_history(&self) -> bool {
matches!(self, Self::IncompleteRollback { .. })
}
pub fn into_reactive_error(self) -> ReactiveError {
match self {
Self::Invalid(error) => error,
Self::IncompleteRollback {
common_ancestor,
oldest_retained,
kind,
} => ReactiveError::InvalidChainControl {
message: format!(
"{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}"
),
},
}
}
}
impl CanonicalSequenceValidationPolicy {
const fn requires_complete_rollback(self) -> bool {
matches!(
self,
Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage
)
}
const fn normalizes_coverage(self) -> bool {
matches!(self, Self::RequireCompleteRollbackNormalizeCoverage)
}
}
fn validate_canonical_sequence_parts<N: Network>(
initial: &CanonicalSequenceState,
controls: &[ChainControl],
records: &[(&ReactiveInputRecord<N>, DeliveryScope)],
policy: CanonicalSequenceValidationPolicy,
) -> Result<CanonicalSequenceValidation, CanonicalSequenceError> {
validate_canonical_sequence_snapshot(initial)?;
let control_split = validate_control_phase_order(controls)?;
let (pre_record_controls, post_record_controls) = controls.split_at(control_split);
let mut state = initial.clone();
let mut asserted_blocks = HashMap::<u64, BlockRef>::new();
let mut mutations = Vec::new();
let mut normalized_chain_controls = Vec::with_capacity(controls.len());
let mut batch_dropped = BatchDroppedCanonical::default();
let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new();
let mut removed_heights_by_hash = HashMap::<B256, u64>::new();
let mut record_proof_control_identities = HashSet::<(u64, B256)>::new();
let rollback_oldest = initial
.retained_canonical_history
.first()
.map(|block| block.number);
for control in pre_record_controls {
normalized_chain_controls.push(control.clone());
validate_sequence_control(&state, control)?;
assert_chain_control_identities(&mut asserted_blocks, control)?;
let ChainControl::Reorg {
common_ancestor,
old_tip,
..
} = control
else {
unreachable!("phase validation leaves only reorg controls before records")
};
let exact_ancestor = state.retained_canonical_history.iter().any(|block| {
block.number == common_ancestor.number && block.hash == common_ancestor.hash
});
let rollback_horizon_covers_ancestor = state
.retained_canonical_history
.first()
.is_some_and(|oldest| oldest.number <= common_ancestor.number);
if policy.requires_complete_rollback()
&& !exact_ancestor
&& !rollback_horizon_covers_ancestor
{
return Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor: common_ancestor.number,
oldest_retained: rollback_oldest,
kind: CanonicalRollbackKind::Explicit,
});
}
let dropped = state
.retained_canonical_history
.iter()
.copied()
.filter(|block| block.number > common_ancestor.number)
.collect::<Vec<_>>();
state
.retained_canonical_history
.retain(|block| block.number <= common_ancestor.number);
upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?;
let mut enriched_ancestor = *common_ancestor;
if let Some(retained) = state.retained_canonical_history.iter().find(|block| {
block.number == common_ancestor.number && block.hash == common_ancestor.hash
}) {
enrich_block_ref(&mut enriched_ancestor, retained);
}
if let Some(coverage) = state.coverage_head.as_ref()
&& coverage.number == common_ancestor.number
&& coverage.hash == common_ancestor.hash
{
enrich_block_ref(&mut enriched_ancestor, coverage);
}
upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?;
state.coverage_head = Some(enriched_ancestor);
clear_sequence_heads_above(&mut state, &enriched_ancestor);
batch_dropped.record_explicit(common_ancestor, old_tip);
batch_dropped.record_drained(&dropped);
mutations.push(CanonicalSequenceMutation::Rewind {
common_ancestor: Some(enriched_ancestor),
dropped,
});
}
let pre_record_state = state.clone();
let mut required_reorg_anchor = None::<RequiredReorgAnchor>;
for (record, scope) in records {
if !scope.advances_canonical_state() {
continue;
}
if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) {
let incoming_dropped_block =
resolve_record_block_payload_metadata(record, incoming_dropped_block)?;
validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?;
validate_sequence_adjacent_parent_identity(
&state,
&incoming_dropped_block,
"removed record",
)?;
let mut dropped_block = state
.retained_canonical_history
.iter()
.find(|known| {
known.number == incoming_dropped_block.number
&& known.hash == incoming_dropped_block.hash
})
.copied()
.or_else(|| {
state.coverage_head.filter(|known| {
known.number == incoming_dropped_block.number
&& known.hash == incoming_dropped_block.hash
})
})
.unwrap_or(incoming_dropped_block);
enrich_block_ref(&mut dropped_block, &incoming_dropped_block);
validate_sequence_implicit_finality(&state, record, None)?;
if dropped_block.number == 0 {
return Err(ReactiveError::InvalidChainControl {
message: "a removed/reorged genesis block has no canonical parent anchor"
.into(),
}
.into());
}
let removed_identity = (dropped_block.number, dropped_block.hash);
if let Some(previous_number) =
removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number)
&& previous_number != dropped_block.number
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"removed hash {:?} is reused at heights {} and {}",
dropped_block.hash, previous_number, dropped_block.number
),
}
.into());
}
if let Some(previous) = removed_assertions.get_mut(&removed_identity) {
if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"duplicate removed block {}:{:?} carries conflicting metadata",
dropped_block.number, dropped_block.hash
),
}
.into());
}
enrich_block_ref(previous, &dropped_block);
} else {
removed_assertions.insert(removed_identity, dropped_block);
}
if asserted_blocks
.get(&dropped_block.number)
.is_some_and(|asserted| asserted.hash == dropped_block.hash)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"removed block {}:{:?} is asserted canonical by the same envelope",
dropped_block.number, dropped_block.hash
),
}
.into());
}
if batch_dropped.contains(&dropped_block) {
continue;
}
if let Some(index) = state.retained_canonical_history.iter().position(|block| {
block.number == dropped_block.number && block.hash == dropped_block.hash
}) {
let dropped = state.retained_canonical_history.split_off(index);
batch_dropped.record_drained(&dropped);
let ancestor_number = dropped_block
.number
.checked_sub(1)
.expect("genesis removal was rejected above");
let retained_anchor = state
.retained_canonical_history
.iter()
.rev()
.find(|head| head.number == ancestor_number)
.copied();
let authenticated_anchor = retained_anchor
.or_else(|| {
dropped_block.parent_hash.map(|hash| BlockRef {
number: ancestor_number,
hash,
parent_hash: None,
timestamp: None,
})
})
.or_else(|| {
state
.finalized_head
.filter(|head| head.number == ancestor_number)
});
let authenticated_anchor = authenticated_anchor.map(|mut anchor| {
for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()]
.into_iter()
.flatten()
{
if head.number == anchor.number && head.hash == anchor.hash {
enrich_block_ref(&mut anchor, head);
}
}
anchor
});
required_reorg_anchor = Some(RequiredReorgAnchor {
number: ancestor_number,
block: authenticated_anchor,
permits_missing_child_parent: retained_anchor.is_some(),
must_be_consumed: authenticated_anchor.is_none()
&& state.retained_canonical_history.is_empty(),
});
state.coverage_head = authenticated_anchor
.or_else(|| state.retained_canonical_history.last().copied());
if let Some(head) = state.coverage_head {
clear_sequence_heads_above(&mut state, &head);
} else {
state.safe_head = None;
state.finalized_head = None;
}
mutations.push(CanonicalSequenceMutation::Rewind {
common_ancestor: state.coverage_head,
dropped,
});
} else {
let replacement_is_known = state.retained_canonical_history.iter().any(|block| {
block.number == dropped_block.number && block.hash != dropped_block.hash
}) || state.coverage_head.is_some_and(|head| {
head.number == dropped_block.number && head.hash != dropped_block.hash
});
if !replacement_is_known {
if policy.requires_complete_rollback() {
return Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor: dropped_block
.number
.checked_sub(1)
.expect("genesis removal was rejected above"),
oldest_retained: rollback_oldest,
kind: CanonicalRollbackKind::Removed,
});
}
continue;
}
}
continue;
}
let Some(context_block) = canonical_record_block(record) else {
continue;
};
let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?;
if post_record_controls
.iter()
.filter_map(canonical_coverage_control_block)
.any(|asserted| {
asserted.number == incoming_block.number
&& asserted.hash == incoming_block.hash
&& optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block))
&& ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some())
|| (incoming_block.timestamp.is_none() && asserted.timestamp.is_some()))
})
{
record_proof_control_identities.insert((incoming_block.number, incoming_block.hash));
}
let mut resolved_block = incoming_block;
if let Some(asserted) = asserted_blocks
.get(&incoming_block.number)
.filter(|asserted| asserted.hash == incoming_block.hash)
{
if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
incoming_block.number, incoming_block.hash
),
}
.into());
}
enrich_block_ref(&mut resolved_block, asserted);
}
for asserted in post_record_controls
.iter()
.filter_map(chain_control_canonical_assertion)
.filter(|asserted| {
asserted.number == incoming_block.number && asserted.hash == incoming_block.hash
})
{
if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical record {}:{:?} conflicts with the same envelope's asserted metadata",
incoming_block.number, incoming_block.hash
),
}
.into());
}
enrich_block_ref(&mut resolved_block, asserted);
}
let replacement_anchor =
required_reorg_anchor.filter(|required| resolved_block.number > required.number);
if resolved_block.parent_hash.is_none()
&& replacement_anchor.is_some_and(|anchor| {
anchor.permits_missing_child_parent
&& anchor.number.checked_add(1) == Some(resolved_block.number)
})
{
resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash);
}
let block = &resolved_block;
if removed_assertions.contains_key(&(block.number, block.hash)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical block {}:{:?} is also removed by the same envelope",
block.number, block.hash
),
}
.into());
}
if let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
&& *removed_number != block.number
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical hash {:?} at height {} is removed at height {} by the same envelope",
block.hash, block.number, removed_number
),
}
.into());
}
let replacement_proven_by_removal =
validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
if replacement_anchor.is_some() {
required_reorg_anchor = None;
}
validate_sequence_matching_metadata(&state, block, "canonical record")?;
validate_sequence_implicit_finality(&state, record, Some(block))?;
let implicit_replacement_requires_history = if replacement_proven_by_removal {
false
} else {
sequence_implicit_replacement_requires_history(&state, block, policy)?
};
if implicit_replacement_requires_history && policy.requires_complete_rollback() {
return Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor: block.number.saturating_sub(1),
oldest_retained: rollback_oldest,
kind: CanonicalRollbackKind::ImplicitParent,
});
}
assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?;
let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
anchor.permits_missing_child_parent
&& anchor.number.checked_add(1) == Some(block.number)
});
if let Some(rewind) =
apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
{
mutations.push(CanonicalSequenceMutation::Rewind {
common_ancestor: rewind.common_ancestor,
dropped: rewind.dropped,
});
}
mutations.push(CanonicalSequenceMutation::Canonical(*block));
}
for control in post_record_controls {
if let Some(block) = chain_control_canonical_assertion(control)
&& removed_assertions.contains_key(&(block.number, block.hash))
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical block {}:{:?} is also removed by the same envelope",
block.number, block.hash
),
}
.into());
}
if let Some(block) = chain_control_canonical_assertion(control)
&& let Some(removed_number) = removed_heights_by_hash.get(&block.hash)
&& *removed_number != block.number
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical hash {:?} at height {} is removed at height {} by the same envelope",
block.hash, block.number, removed_number
),
}
.into());
}
let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| {
required_reorg_anchor.filter(|required| block.number > required.number)
});
if let Some(block) = canonical_coverage_control_block(control) {
validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?;
if replacement_anchor.is_some() {
required_reorg_anchor = None;
}
}
assert_chain_control_identities(&mut asserted_blocks, control)?;
let preserves_record_proof =
canonical_coverage_control_block(control).is_some_and(|block| {
record_proof_control_identities.contains(&(block.number, block.hash))
});
if policy.normalizes_coverage()
&& !preserves_record_proof
&& let Some(block) = canonical_coverage_control_block(control)
&& state
.coverage_head
.is_some_and(|head| block.number <= head.number)
{
let is_equal_coverage = state
.coverage_head
.is_some_and(|head| block.number == head.number);
let known = state
.coverage_head
.as_ref()
.filter(|head| head.number == block.number && head.hash == block.hash)
.or_else(|| {
state
.retained_canonical_history
.iter()
.find(|entry| entry.number == block.number && entry.hash == block.hash)
});
if let Some(known) = known
&& optional_block_refs_are_compatible(Some(known), Some(block))
&& (!is_equal_coverage || !sequence_block_adds_metadata(&state, block))
{
if let ChainControl::Barrier { id, .. } = control {
normalized_chain_controls.push(ChainControl::Barrier {
id: id.clone(),
block: None,
});
}
continue;
}
}
validate_sequence_control(&state, control)?;
normalized_chain_controls.push(control.clone());
match control {
ChainControl::Safe(block) => {
set_or_enrich_block_ref(&mut state.safe_head, block);
mutations.push(CanonicalSequenceMutation::Safe(
state.safe_head.expect("safe head was just installed"),
));
}
ChainControl::Finalized(block) => {
set_or_enrich_block_ref(&mut state.finalized_head, block);
mutations.push(CanonicalSequenceMutation::Finalized(
state
.finalized_head
.expect("finalized head was just installed"),
));
}
ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => {
let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| {
anchor.permits_missing_child_parent
&& anchor.number.checked_add(1) == Some(block.number)
}) || (replacement_anchor.is_none()
&& block.parent_hash.is_none()
&& state
.coverage_head
.is_some_and(|head| head.number.checked_add(1) == Some(block.number)));
if let Some(rewind) =
apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)?
{
mutations.push(CanonicalSequenceMutation::Rewind {
common_ancestor: rewind.common_ancestor,
dropped: rewind.dropped,
});
}
mutations.push(CanonicalSequenceMutation::Canonical(*block));
}
ChainControl::Barrier { block: None, .. } => {}
ChainControl::Reorg { .. } => {
unreachable!("phase validation excludes post-record reorg controls")
}
}
}
if let Some(required) = required_reorg_anchor
&& required.must_be_consumed
&& policy.requires_complete_rollback()
{
return Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor: required.number,
oldest_retained: rollback_oldest,
kind: CanonicalRollbackKind::MissingReplacement,
});
}
validate_canonical_sequence_snapshot(&state)?;
Ok(CanonicalSequenceValidation {
pre_record_state,
next_state: state,
mutations,
normalized_chain_controls,
})
}
fn validate_canonical_sequence_snapshot(
state: &CanonicalSequenceState,
) -> Result<(), ReactiveError> {
let invalid = |message: String| ReactiveError::InvalidChainControl { message };
let supplied_blocks = state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
.collect::<Vec<_>>();
validate_known_parent_hash_heights(&supplied_blocks)?;
let mut prior = None::<BlockRef>;
for block in &state.retained_canonical_history {
if let Some(previous) = prior {
if block.number < previous.number {
return Err(invalid(
"retained canonical history is not ordered by block number".into(),
));
}
if block.number == previous.number {
let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block))
{
"duplicate"
} else {
"conflicting"
};
return Err(invalid(format!(
"retained canonical history contains {qualifier} identities at block {}",
block.number
)));
}
if previous.number.checked_add(1) == Some(block.number)
&& block.parent_hash.is_some()
&& block.parent_hash != Some(previous.hash)
{
return Err(invalid(format!(
"adjacent retained block {}:{:?} does not descend from {}:{:?}",
block.number, block.hash, previous.number, previous.hash
)));
}
}
prior = Some(*block);
}
if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() {
return Err(invalid(
"retained canonical history requires an authoritative coverage head".into(),
));
}
if let Some(head) = state.coverage_head.as_ref() {
if let Some(retained) = state
.retained_canonical_history
.iter()
.find(|entry| entry.number == head.number)
&& !optional_block_refs_are_compatible(Some(retained), Some(head))
{
return Err(invalid(format!(
"coverage head {}:{:?} conflicts with retained identity {:?}",
head.number, head.hash, retained
)));
}
if state
.retained_canonical_history
.last()
.is_some_and(|retained| retained.number > head.number)
{
return Err(invalid(
"retained canonical history advances beyond the coverage head".into(),
));
}
if let Some(retained) = state.retained_canonical_history.last()
&& retained.number.checked_add(1) == Some(head.number)
&& head.parent_hash.is_some()
&& head.parent_hash != Some(retained.hash)
{
return Err(invalid(format!(
"coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}",
head.number, head.hash, retained.number, retained.hash
)));
}
}
if let Some(safe) = state.safe_head.as_ref() {
validate_sequence_known_identity(state, safe, "safe")?;
validate_sequence_head_within_coverage(state, safe, "safe")?;
validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?;
}
if let Some(finalized) = state.finalized_head.as_ref() {
validate_sequence_known_identity(state, finalized, "finalized")?;
validate_sequence_head_within_coverage(state, finalized, "finalized")?;
validate_coverage_descends_from_adjacent_head(
state.coverage_head.as_ref(),
finalized,
"finalized",
)?;
}
validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?;
if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head)
&& (finalized.number > safe.number
|| (finalized.number == safe.number && finalized.hash != safe.hash))
{
return Err(invalid(
"finalized head cannot advance beyond or conflict with safe head".into(),
));
}
Ok(())
}
fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> {
let mut heights_by_hash = HashMap::<B256, u64>::with_capacity(blocks.len());
let mut resolved_by_height = HashMap::<u64, BlockRef>::with_capacity(blocks.len());
for block in blocks.iter().copied() {
if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number)
&& previous_height != block.number
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical hash {:?} is reused at heights {} and {}",
block.hash, previous_height, block.number
),
});
}
if let Some(resolved) = resolved_by_height.get_mut(&block.number) {
if !optional_block_refs_are_compatible(Some(resolved), Some(block)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical aliases at height {} carry conflicting identities or metadata",
block.number
),
});
}
enrich_block_ref(resolved, block);
} else {
resolved_by_height.insert(block.number, *block);
}
}
for child in resolved_by_height.values() {
let Some(parent_hash) = child.parent_hash else {
continue;
};
if let Some(parent_number) = heights_by_hash.get(&parent_hash)
&& parent_number.checked_add(1) != Some(child.number)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
child.number, child.hash, parent_hash, parent_number
),
});
}
if let Some(parent_number) = child.number.checked_sub(1)
&& let Some(parent) = resolved_by_height.get(&parent_number)
&& parent.hash != parent_hash
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"block {}:{:?} does not descend from supplied adjacent identity {}:{:?}",
child.number, child.hash, parent.number, parent.hash
),
});
}
}
Ok(())
}
fn validate_coverage_descends_from_adjacent_head(
coverage: Option<&BlockRef>,
head: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
let Some(coverage) = coverage else {
return Ok(());
};
if head.number.checked_add(1) == Some(coverage.number)
&& coverage
.parent_hash
.is_some_and(|parent| parent != head.hash)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}",
coverage.number, coverage.hash, head.number, head.hash
),
});
}
Ok(())
}
fn validate_sequence_control(
state: &CanonicalSequenceState,
control: &ChainControl,
) -> Result<(), ReactiveError> {
let invalid = |message: String| ReactiveError::InvalidChainControl { message };
match control {
ChainControl::Safe(block) => {
validate_sequence_known_identity(state, block, "safe")?;
validate_sequence_head_within_coverage(state, block, "safe")?;
if let Some(current) = state.safe_head.as_ref()
&& (block.number < current.number
|| (block.number == current.number
&& (block.hash != current.hash
|| !optional_block_refs_are_compatible(Some(block), Some(current)))))
{
return Err(invalid(format!(
"safe head {}:{:?} conflicts with current {}:{:?}",
block.number, block.hash, current.number, current.hash
)));
}
if let Some(finalized) = state.finalized_head.as_ref()
&& (block.number < finalized.number
|| (block.number == finalized.number && block.hash != finalized.hash))
{
return Err(invalid(
"safe head cannot precede or conflict with finalized head".into(),
));
}
validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?;
}
ChainControl::Finalized(block) => {
validate_sequence_known_identity(state, block, "finalized")?;
validate_sequence_head_within_coverage(state, block, "finalized")?;
if let Some(current) = state.finalized_head.as_ref()
&& (block.number < current.number
|| (block.number == current.number
&& (block.hash != current.hash
|| !optional_block_refs_are_compatible(Some(block), Some(current)))))
{
return Err(invalid(format!(
"finalized head {}:{:?} conflicts with current {}:{:?}",
block.number, block.hash, current.number, current.hash
)));
}
if let Some(safe) = state.safe_head.as_ref()
&& (block.number > safe.number
|| (block.number == safe.number && block.hash != safe.hash))
{
return Err(invalid(
"finalized head cannot advance beyond or conflict with safe head".into(),
));
}
validate_adjacent_finality(Some(block), state.safe_head.as_ref())?;
}
ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => {
validate_sequence_known_identity(state, block, "canonical coverage")?;
if let Some(current) = state.coverage_head.as_ref()
&& (block.number < current.number
|| (block.number == current.number && block.hash != current.hash))
{
return Err(invalid(format!(
"canonical coverage {}:{:?} conflicts with current {}:{:?}",
block.number, block.hash, current.number, current.hash
)));
}
if let Some(current) = state.coverage_head.as_ref()
&& current.number.checked_add(1) == Some(block.number)
&& block.parent_hash.is_some()
&& block.parent_hash != Some(current.hash)
{
return Err(invalid(format!(
"canonical coverage {}:{:?} does not descend from current {}:{:?}",
block.number, block.hash, current.number, current.hash
)));
}
}
ChainControl::Barrier { block: None, .. } => {}
ChainControl::Reorg {
common_ancestor,
old_tip,
new_tip,
} => {
validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?;
validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?;
validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?;
validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?;
validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?;
validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?;
validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?;
if let Some(current) = state.coverage_head.as_ref()
&& (old_tip.number != current.number
|| old_tip.hash != current.hash
|| !optional_block_refs_are_compatible(Some(old_tip), Some(current)))
{
return Err(invalid(format!(
"reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}",
old_tip.number, old_tip.hash, current.number, current.hash
)));
}
if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number {
return Err(invalid(
"reorg common ancestor cannot be above either branch tip".into(),
));
}
if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number
{
return Err(invalid(
"reorg must replace non-empty old and new branches above the common ancestor"
.into(),
));
}
if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash {
return Err(invalid(
"reorg old and new tips cannot have the same canonical identity".into(),
));
}
for (label, tip) in [("old", old_tip), ("new", new_tip)] {
if common_ancestor.number.checked_add(1) == Some(tip.number)
&& tip.parent_hash != Some(common_ancestor.hash)
{
return Err(invalid(format!(
"reorg {label} tip does not descend from the common ancestor"
)));
}
}
if let Some(finalized) = state.finalized_head.as_ref()
&& (common_ancestor.number < finalized.number
|| (common_ancestor.number == finalized.number
&& common_ancestor.hash != finalized.hash))
{
return Err(invalid(
"reorg would cross or conflict with the finalized head".into(),
));
}
}
}
Ok(())
}
fn validate_sequence_known_identity(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
validate_sequence_known_hash_height(state, block, label)?;
validate_sequence_known_parent_height(state, block, label)?;
let known = state
.coverage_head
.as_ref()
.filter(|head| head.number == block.number)
.or_else(|| {
state
.retained_canonical_history
.iter()
.find(|entry| entry.number == block.number)
});
if let Some(known) = known
&& !optional_block_refs_are_compatible(Some(known), Some(block))
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} conflicts with known canonical block {:?}",
block.number, block.hash, known
),
});
}
Ok(())
}
fn validate_sequence_known_parent_height(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
let Some(parent_hash) = block.parent_hash else {
return Ok(());
};
let known_parent = state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
.find(|known| known.hash == parent_hash);
if let Some(parent) = known_parent
&& parent.number.checked_add(1) != Some(block.number)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
block.number, block.hash, parent.hash, parent.number
),
});
}
Ok(())
}
fn validate_sequence_head_within_coverage(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
let Some(coverage) = state.coverage_head.as_ref() else {
return Err(ReactiveError::InvalidChainControl {
message: format!("{label} head requires an authoritative coverage head"),
});
};
if block.number > coverage.number
|| (block.number == coverage.number
&& !optional_block_refs_are_compatible(Some(block), Some(coverage)))
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}",
block.number, block.hash, coverage.number, coverage.hash
),
});
}
Ok(())
}
fn validate_sequence_matching_metadata(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
validate_sequence_known_hash_height(state, block, label)?;
validate_sequence_known_parent_height(state, block, label)?;
let known = state
.coverage_head
.as_ref()
.filter(|head| head.number == block.number && head.hash == block.hash)
.or_else(|| {
state
.retained_canonical_history
.iter()
.find(|entry| entry.number == block.number && entry.hash == block.hash)
});
if let Some(known) = known
&& !optional_block_refs_are_compatible(Some(known), Some(block))
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}",
block.number, block.hash, known
),
});
}
Ok(())
}
fn validate_sequence_known_hash_height(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
let known = state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
.find(|known| known.hash == block.hash);
if let Some(known) = known
&& known.number != block.number
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} reuses a canonical hash already known at height {}",
block.number, block.hash, known.number
),
});
}
Ok(())
}
fn validate_reorg_ancestor_against_retained_branch(
state: &CanonicalSequenceState,
ancestor: &BlockRef,
) -> Result<(), ReactiveError> {
let adjacent_number = ancestor.number.checked_add(1);
for retained in state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
{
if Some(retained.number) == adjacent_number
&& retained
.parent_hash
.is_some_and(|parent| parent != ancestor.hash)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}",
ancestor.number,
ancestor.hash,
retained.number,
retained.hash,
retained.parent_hash
),
});
}
if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}",
ancestor.number, ancestor.hash, retained.number, retained.hash
),
});
}
}
Ok(())
}
fn validate_sequence_adjacent_parent_identity(
state: &CanonicalSequenceState,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
let Some(parent_hash) = block.parent_hash else {
return Ok(());
};
let Some(parent_number) = block.number.checked_sub(1) else {
return Ok(());
};
let known_parent = state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
.find(|known| known.number == parent_number);
if let Some(known_parent) = known_parent
&& known_parent.hash != parent_hash
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
),
});
}
Ok(())
}
fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool {
state
.coverage_head
.iter()
.chain(state.retained_canonical_history.iter())
.filter(|known| known.number == incoming.number && known.hash == incoming.hash)
.any(|known| {
(known.parent_hash.is_none() && incoming.parent_hash.is_some())
|| (known.timestamp.is_none() && incoming.timestamp.is_some())
})
}
fn validate_sequence_implicit_finality<N: Network>(
state: &CanonicalSequenceState,
record: &ReactiveInputRecord<N>,
resolved_canonical_block: Option<&BlockRef>,
) -> Result<(), ReactiveError> {
let Some(finalized) = state.finalized_head.as_ref() else {
return Ok(());
};
if let Some((dropped, _)) = reorg_signal_block(record) {
if dropped.number <= finalized.number {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"implicit reorg at {}:{:?} would cross finalized head {}:{:?}",
dropped.number, dropped.hash, finalized.number, finalized.hash
),
});
}
return Ok(());
}
let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else {
return Ok(());
};
let Some(latest) = state.coverage_head.as_ref() else {
return Ok(());
};
if (block.number == latest.number && block.hash == latest.hash)
|| state
.retained_canonical_history
.iter()
.any(|entry| entry.number == block.number && entry.hash == block.hash)
|| (latest.number.checked_add(1) == Some(block.number)
&& block.parent_hash == Some(latest.hash))
|| latest
.number
.checked_add(1)
.is_some_and(|next| block.number > next)
{
return Ok(());
}
let crosses_finalized = if block.number <= finalized.number {
true
} else if let Some(parent_hash) = block.parent_hash {
if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash {
false
} else if let Some(parent_index) =
state.retained_canonical_history.iter().rposition(|entry| {
entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
})
{
state
.retained_canonical_history
.iter()
.skip(parent_index + 1)
.any(|entry| entry.number <= finalized.number)
} else {
true
}
} else {
true
};
if crosses_finalized {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical input {}:{:?} would replace finalized head {}:{:?}",
block.number, block.hash, finalized.number, finalized.hash
),
});
}
Ok(())
}
fn validate_required_reorg_anchor(
required: Option<RequiredReorgAnchor>,
block: &BlockRef,
) -> Result<(), ReactiveError> {
let Some(required) = required else {
return Ok(());
};
let ancestor_hash = required.hash();
let restores_ancestor =
block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash);
let replaces_removed_child = required.number.checked_add(1) == Some(block.number)
&& ancestor_hash.is_some()
&& (block.parent_hash == ancestor_hash
|| (block.parent_hash.is_none() && required.permits_missing_child_parent));
if restores_ancestor || replaces_removed_child {
return Ok(());
}
Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical replacement {}:{:?} does not prove the removed tip's parent at block {}",
block.number, block.hash, required.number
),
})
}
fn validate_replacement_reorg_anchor(
required: Option<RequiredReorgAnchor>,
block: &BlockRef,
policy: CanonicalSequenceValidationPolicy,
oldest_retained: Option<u64>,
) -> Result<bool, CanonicalSequenceError> {
let Some(required) = required else {
return Ok(false);
};
match validate_required_reorg_anchor(Some(required), block) {
Ok(()) => Ok(true),
Err(error) if required.block.is_some() => Err(error.into()),
Err(_) if policy.requires_complete_rollback() => {
Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor: required.number,
oldest_retained,
kind: CanonicalRollbackKind::MissingReplacement,
})
}
Err(_) => Ok(false),
}
}
fn apply_sequence_canonical_block(
state: &mut CanonicalSequenceState,
block: &BlockRef,
allow_parentless_adjacent_extension: bool,
) -> Result<Option<SequenceRewind>, ReactiveError> {
let latest = state.coverage_head;
let already_known = state
.retained_canonical_history
.iter()
.any(|entry| entry.number == block.number && entry.hash == block.hash);
let repeats_tip =
latest.is_some_and(|head| head.number == block.number && head.hash == block.hash);
let extends_tip = latest.is_some_and(|head| {
head.number.checked_add(1) == Some(block.number)
&& (block.parent_hash == Some(head.hash)
|| (allow_parentless_adjacent_extension && block.parent_hash.is_none()))
});
let forward_gap = latest.is_some_and(|head| {
head.number
.checked_add(1)
.is_some_and(|next| block.number > next)
});
let mut rewind = None;
if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap {
let retained_parent = block.parent_hash.and_then(|parent_hash| {
state
.retained_canonical_history
.iter()
.rposition(|entry| {
entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
})
.map(|index| (index, state.retained_canonical_history[index]))
});
let finalized_parent = block.parent_hash.and_then(|parent_hash| {
state.finalized_head.filter(|finalized| {
finalized.number.checked_add(1) == Some(block.number)
&& finalized.hash == parent_hash
})
});
let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent {
let dropped = state.retained_canonical_history.split_off(parent_index + 1);
(Some(parent), dropped)
} else if let Some(finalized) = finalized_parent {
let dropped = state
.retained_canonical_history
.iter()
.position(|entry| entry.number > finalized.number)
.map_or_else(Vec::new, |index| {
state.retained_canonical_history.split_off(index)
});
(Some(finalized), dropped)
} else {
(None, std::mem::take(&mut state.retained_canonical_history))
};
state.coverage_head = common_ancestor;
if let Some(common_ancestor) = common_ancestor {
clear_sequence_heads_above(state, &common_ancestor);
} else {
state.safe_head = None;
state.finalized_head = None;
}
rewind = Some(SequenceRewind {
common_ancestor,
dropped,
});
}
upsert_sequence_history(&mut state.retained_canonical_history, block)?;
advance_or_enrich_coverage(&mut state.coverage_head, block);
Ok(rewind)
}
fn sequence_implicit_replacement_requires_history(
state: &CanonicalSequenceState,
block: &BlockRef,
policy: CanonicalSequenceValidationPolicy,
) -> Result<bool, ReactiveError> {
let Some(latest) = state.coverage_head else {
return Ok(false);
};
let already_known = state
.retained_canonical_history
.iter()
.any(|entry| entry.number == block.number && entry.hash == block.hash);
let repeats_tip = block.number == latest.number && block.hash == latest.hash;
let extends_tip = latest.number.checked_add(1) == Some(block.number)
&& block.parent_hash == Some(latest.hash);
let forward_gap = latest
.number
.checked_add(1)
.is_some_and(|next| block.number > next);
if already_known || repeats_tip || extends_tip || forward_gap {
return Ok(false);
}
let Some(parent_hash) = block.parent_hash else {
if policy.requires_complete_rollback() {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"implicit canonical replacement {}:{:?} must identify its parent",
block.number, block.hash
),
});
}
return Ok(true);
};
let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| {
state
.retained_canonical_history
.iter()
.chain(state.coverage_head.iter())
.chain(state.safe_head.iter())
.chain(state.finalized_head.iter())
.find(|known| known.number == parent_number)
});
if let Some(known_parent) = known_adjacent_parent
&& known_parent.hash != parent_hash
&& policy.requires_complete_rollback()
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}",
block.number, block.hash, parent_hash, known_parent.number, known_parent.hash
),
});
}
let retained_parent = state.retained_canonical_history.iter().any(|entry| {
entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash
});
let finalized_parent = state.finalized_head.is_some_and(|finalized| {
finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash
});
Ok(!retained_parent && !finalized_parent)
}
fn upsert_sequence_history(
history: &mut Vec<BlockRef>,
block: &BlockRef,
) -> Result<(), ReactiveError> {
if let Some(existing) = history
.iter_mut()
.find(|entry| entry.number == block.number)
{
if existing.hash != block.hash {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical block {}:{:?} conflicts with retained identity {:?}",
block.number, block.hash, existing
),
});
}
if !optional_block_refs_are_compatible(Some(existing), Some(block)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"canonical block {}:{:?} carries conflicting retained metadata",
block.number, block.hash
),
});
}
enrich_block_ref(existing, block);
} else {
history.push(*block);
history.sort_by_key(|entry| entry.number);
}
Ok(())
}
fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) {
if state.safe_head.as_ref().is_some_and(|head| {
head.number > ancestor.number
|| (head.number == ancestor.number && head.hash != ancestor.hash)
}) {
state.safe_head = None;
}
if state.finalized_head.as_ref().is_some_and(|head| {
head.number > ancestor.number
|| (head.number == ancestor.number && head.hash != ancestor.hash)
}) {
state.finalized_head = None;
}
}
fn validate_control_phase_order(controls: &[ChainControl]) -> Result<usize, ReactiveError> {
let split = controls
.iter()
.position(|control| !matches!(control, ChainControl::Reorg { .. }))
.unwrap_or(controls.len());
if controls[split..]
.iter()
.any(|control| matches!(control, ChainControl::Reorg { .. }))
{
return Err(ReactiveError::InvalidChainControl {
message: "reorg controls must precede records and all post-record controls in a batch"
.into(),
});
}
Ok(split)
}
fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> {
match control {
ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => Some(block),
ChainControl::Reorg { .. }
| ChainControl::Safe(_)
| ChainControl::Finalized(_)
| ChainControl::Barrier { block: None, .. } => None,
}
}
fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> {
match control {
ChainControl::Safe(block)
| ChainControl::Finalized(block)
| ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => Some(block),
ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None,
}
}
fn assert_chain_control_identities(
asserted_blocks: &mut HashMap<u64, BlockRef>,
control: &ChainControl,
) -> Result<(), ReactiveError> {
match control {
ChainControl::Safe(block)
| ChainControl::Finalized(block)
| ChainControl::CanonicalProgress(block)
| ChainControl::Barrier {
block: Some(block), ..
} => assert_canonical_block_identity(asserted_blocks, block, "chain control"),
ChainControl::Barrier { block: None, .. } => Ok(()),
ChainControl::Reorg {
common_ancestor,
new_tip,
..
} => {
asserted_blocks.retain(|number, _| *number <= common_ancestor.number);
assert_canonical_block_identity(
asserted_blocks,
common_ancestor,
"reorg common ancestor",
)?;
assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip")
}
}
}
fn assert_canonical_block_identity(
asserted_blocks: &mut HashMap<u64, BlockRef>,
block: &BlockRef,
label: &str,
) -> Result<(), ReactiveError> {
for asserted in asserted_blocks.values() {
if asserted.hash == block.hash && asserted.number != block.number {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} hash {:?} is already asserted at height {}, not {}",
block.hash, asserted.number, block.number
),
});
}
if block
.parent_hash
.is_some_and(|parent| parent == asserted.hash)
&& asserted.number.checked_add(1) != Some(block.number)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent",
block.number, block.hash, asserted.hash, asserted.number
),
});
}
if asserted
.parent_hash
.is_some_and(|parent| parent == block.hash)
&& block.number.checked_add(1) != Some(asserted.number)
{
return Err(ReactiveError::InvalidChainControl {
message: format!(
"block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent",
asserted.number, asserted.hash, block.hash, block.number
),
});
}
}
if let Some(known) = asserted_blocks.get_mut(&block.number) {
if !optional_block_refs_are_compatible(Some(known), Some(block)) {
return Err(ReactiveError::InvalidChainControl {
message: format!(
"{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch",
block.number, block.hash, known
),
});
}
enrich_block_ref(known, block);
} else {
asserted_blocks.insert(block.number, *block);
}
Ok(())
}
fn set_or_enrich_block_ref(current: &mut Option<BlockRef>, incoming: &BlockRef) {
match current {
Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
enrich_block_ref(current, incoming);
}
_ => *current = Some(*incoming),
}
}
fn advance_or_enrich_coverage(current: &mut Option<BlockRef>, incoming: &BlockRef) {
match current {
Some(current) if current.number == incoming.number && current.hash == incoming.hash => {
enrich_block_ref(current, incoming);
}
Some(current) if current.number >= incoming.number => {}
_ => *current = Some(*incoming),
}
}
fn validate_adjacent_finality(
finalized: Option<&BlockRef>,
safe: Option<&BlockRef>,
) -> Result<(), ReactiveError> {
let Some((finalized, safe)) = finalized.zip(safe) else {
return Ok(());
};
if finalized.number.checked_add(1) == Some(safe.number)
&& safe.parent_hash != Some(finalized.hash)
{
return Err(ReactiveError::InvalidChainControl {
message: "adjacent safe head does not descend from finalized head".into(),
});
}
Ok(())
}
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 batch_preconfirmation<N: Network>(
batch: &ReactiveInputBatch<N>,
) -> Result<Option<FlashblockRef>, ReactiveError> {
let mut flashblock: Option<FlashblockRef> = None;
let mut has_non_preconfirmed = false;
for (index, record) in batch.records().iter().enumerate() {
match &record.context.chain_status {
ChainStatus::Preconfirmed {
flashblock: current,
} => {
if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) {
return Err(ReactiveError::InvalidInputRecord {
message: "pre-confirmed input requires pre-confirmed delivery scope".into(),
});
}
if flashblock
.as_ref()
.is_some_and(|known| known != current.as_ref())
{
return Err(ReactiveError::InvalidInputRecord {
message: "one batch cannot mix distinct Flashblock snapshots".into(),
});
}
flashblock.get_or_insert_with(|| current.as_ref().clone());
}
_ => has_non_preconfirmed = true,
}
}
if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) {
return Err(ReactiveError::InvalidInputRecord {
message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(),
});
}
Ok(flashblock)
}
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 resolve_record_block_payload_metadata<N: Network>(
record: &ReactiveInputRecord<N>,
mut block: BlockRef,
) -> Result<BlockRef, ReactiveError> {
let ReactiveInput::Log(log) = &record.input else {
return Ok(block);
};
if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) {
return Err(ReactiveError::InvalidInputRecord {
message: "log payload and canonical context carry different block identities".into(),
});
}
if let Some(timestamp) = log.block_timestamp {
if block.timestamp.is_some_and(|known| known != timestamp) {
return Err(ReactiveError::InvalidInputRecord {
message: "log payload and canonical context carry different block timestamps"
.into(),
});
}
block.timestamp = Some(timestamp);
}
Ok(block)
}
fn validate_input_record<N: Network>(record: &ReactiveInputRecord<N>) -> Result<(), ReactiveError> {
let invalid = |message: String| ReactiveError::InvalidInputRecord { message };
if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status
&& record.context.block != Some(flashblock.block_ref())
{
return Err(invalid(
"pre-confirmed status and context carry different partial block identities".into(),
));
}
let status_block = match &record.context.chain_status {
ChainStatus::Included { block, .. }
| ChainStatus::Safe { block }
| ChainStatus::Finalized { block }
| ChainStatus::Reorged {
dropped_from: block,
} => Some(block),
ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(),
ChainStatus::Pending => None,
};
match (status_block, record.context.block.as_ref()) {
(Some(status), Some(context)) if status == context => {}
(Some(_), Some(_)) => {
return Err(invalid(
"chain status and context carry different block identities".into(),
));
}
(Some(_), None) => {
return Err(invalid(
"included or reorged input is missing its context block".into(),
));
}
(None, Some(_)) => {
return Err(invalid(
"pending input cannot carry a canonical context block".into(),
));
}
(None, None) => {}
}
match &record.input {
ReactiveInput::Log(log) => {
let Some(block) = status_block else {
return Err(invalid(
"log input must carry an included or reorged block identity".into(),
));
};
if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) {
return Err(invalid(
"removed log must carry reorged chain status".into(),
));
}
let block_number = log
.block_number
.ok_or_else(|| invalid("log is missing its block number".into()))?;
let block_hash = log
.block_hash
.ok_or_else(|| invalid("log is missing its block hash".into()))?;
log.transaction_hash
.ok_or_else(|| invalid("log is missing its transaction hash".into()))?;
let transaction_index = log
.transaction_index
.ok_or_else(|| invalid("log is missing its transaction index".into()))?;
let log_index = log
.log_index
.ok_or_else(|| invalid("log is missing its log index".into()))?;
if block_number != block.number
|| block_hash != block.hash
|| !optional_metadata_compatible(
log.block_timestamp.as_ref(),
block.timestamp.as_ref(),
)
{
return Err(invalid(
"log payload and context carry different block identities".into(),
));
}
if record.context.transaction_index != Some(transaction_index)
|| record.context.log_index != Some(log_index)
{
return Err(invalid(
"log payload and context carry different transaction/log positions".into(),
));
}
}
ReactiveInput::BlockHeader(header) => {
if let Some(block) = status_block {
if header.number() != block.number
|| header.hash() != block.hash
|| Some(header.parent_hash()) != block.parent_hash
|| Some(header.timestamp()) != block.timestamp
{
return Err(invalid(
"block header payload and context carry different block identities".into(),
));
}
} else if !matches!(record.context.chain_status, ChainStatus::Pending) {
return Err(invalid("block header has an unsupported lifecycle".into()));
}
if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
return Err(invalid(
"block header context cannot carry transaction/log positions".into(),
));
}
}
ReactiveInput::FullBlock(block_response) => {
let header = block_response.header();
if let Some(block) = status_block {
if header.number() != block.number
|| header.hash() != block.hash
|| Some(header.parent_hash()) != block.parent_hash
|| Some(header.timestamp()) != block.timestamp
{
return Err(invalid(
"full-block payload and context carry different block identities".into(),
));
}
} else if !matches!(record.context.chain_status, ChainStatus::Pending) {
return Err(invalid("full block has an unsupported lifecycle".into()));
}
if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
return Err(invalid(
"full-block context cannot carry transaction/log positions".into(),
));
}
if let Some(transactions) = block_response.transactions().as_transactions() {
for (index, transaction) in transactions.iter().enumerate() {
if transaction
.block_hash()
.is_some_and(|hash| hash != header.hash())
|| transaction
.block_number()
.is_some_and(|number| number != header.number())
|| transaction
.transaction_index()
.is_some_and(|position| position != index as u64)
{
return Err(invalid(format!(
"full-block transaction {index} carries contradictory inclusion metadata"
)));
}
if transaction
.chain_id()
.zip(record.context.chain_id)
.is_some_and(|(transaction, context)| transaction != context)
{
return Err(invalid(format!(
"full-block transaction {index} carries a chain id conflicting with its context"
)));
}
}
}
}
ReactiveInput::PendingTxHash(_) => {
if !matches!(record.context.chain_status, ChainStatus::Pending) {
return Err(invalid(
"pending transaction input must carry pending chain status".into(),
));
}
if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
return Err(invalid(
"pending transaction context cannot carry canonical positions".into(),
));
}
}
ReactiveInput::PendingTx(transaction) => {
if !matches!(record.context.chain_status, ChainStatus::Pending) {
return Err(invalid(
"pending transaction input must carry pending chain status".into(),
));
}
if record.context.transaction_index.is_some() || record.context.log_index.is_some() {
return Err(invalid(
"pending transaction context cannot carry canonical positions".into(),
));
}
if transaction.block_hash().is_some()
|| transaction.block_number().is_some()
|| transaction.transaction_index().is_some()
{
return Err(invalid(
"hydrated pending transaction cannot carry inclusion metadata".into(),
));
}
if transaction
.chain_id()
.zip(record.context.chain_id)
.is_some_and(|(transaction, context)| transaction != context)
{
return Err(invalid(
"pending transaction carries a chain id conflicting with its context".into(),
));
}
}
}
Ok(())
}
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::Preconfirmed { .. } => ctx.block.as_ref(),
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, 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 diff in entry.rollback_diffs.iter().rev() {
merge_purge_scopes_for_diff(&mut scopes, 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 diff in entry.rollback_diffs.iter().rev() {
push_rollback_updates_for_diff(&mut updates, 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::Pending => BlockId::pending(),
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) {
let mut candidate = next.clone();
let mut insertion_index = filters.len();
let mut index = 0;
while index < filters.len() {
if filters[index].block_option != candidate.block_option {
index += 1;
continue;
}
if let Some(merged) = exact_filter_union(&candidate, &filters[index]) {
candidate = merged;
insertion_index = insertion_index.min(index);
filters.remove(index);
index = 0;
} else {
index += 1;
}
}
filters.insert(insertion_index.min(filters.len()), candidate);
}
fn exact_filter_union(left: &Filter, right: &Filter) -> Option<Filter> {
if filter_subsumes(left, right) {
return Some(left.clone());
}
if filter_subsumes(right, left) {
return Some(right.clone());
}
let differing_dimensions = usize::from(left.address != right.address)
+ left
.topics
.iter()
.zip(right.topics.iter())
.filter(|(left, right)| left != right)
.count();
if differing_dimensions != 1 {
return None;
}
let mut merged = left.clone();
if merged.address != right.address {
merge_filter_set(&mut merged.address, &right.address);
} else {
for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) {
if merged_topic != right_topic {
merge_filter_set(merged_topic, right_topic);
break;
}
}
}
Some(merged)
}
fn filter_subsumes(left: &Filter, right: &Filter) -> bool {
filter_set_subsumes(&left.address, &right.address)
&& left
.topics
.iter()
.zip(right.topics.iter())
.all(|(left, right)| filter_set_subsumes(left, right))
}
fn filter_set_subsumes<T: Eq + Hash>(left: &FilterSet<T>, right: &FilterSet<T>) -> bool {
left.is_empty()
|| (!right.is_empty()
&& right
.iter()
.all(|value| left.iter().any(|known| known == value)))
}
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,
preconfirmed: bool,
) -> 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(mut request) => {
if preconfirmed {
request.block = ResyncBlock::Pending;
}
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>>,
) -> Result<Vec<ReactiveInputRecord<N>>, ReactiveError> {
let mut positions = HashMap::<ReactiveInputIdentity, usize>::new();
let mut deduped = Vec::with_capacity(records.len());
for record in records {
let identity = record.validated_identity()?;
if !record.is_payload_deduplicable() {
deduped.push(record);
continue;
}
if let Some(index) = positions.get(&identity).copied() {
let merged = deduped[index].merge_compatible_duplicate(&record)?;
debug_assert!(merged, "same indexed identity is deduplicable");
} else {
positions.insert(identity, deduped.len());
deduped.push(record);
}
}
Ok(deduped)
}
fn dedupe_scoped_records<N: Network>(
records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
) -> Result<Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>, ReactiveError> {
let mut positions: HashMap<ReactiveInputIdentity, usize> = HashMap::new();
let mut deduped: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> =
Vec::with_capacity(records.len());
for (record, audience, delivery_scope) in records {
let identity = record.validated_identity()?;
if !record.is_payload_deduplicable() {
deduped.push((record, audience, delivery_scope));
continue;
}
if let Some(index) = positions.get(&identity).copied() {
let merged = deduped[index].0.merge_compatible_duplicate(&record)?;
debug_assert!(merged, "same indexed identity is deduplicable");
merge_delivery_audience(&mut deduped[index].1, audience);
merge_delivery_scope(&mut deduped[index].2, delivery_scope);
} else {
positions.insert(identity, deduped.len());
deduped.push((record, audience, delivery_scope));
}
}
Ok(deduped)
}
fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) {
*into = match (*into, incoming) {
(DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical,
(DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => {
DeliveryScope::CanonicalProgress
}
(DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed)
| (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup)
| (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed,
(DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup,
};
}
fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) {
match (&mut *into, incoming) {
(DeliveryAudience::All, _) => {}
(current, DeliveryAudience::All) => *current = DeliveryAudience::All,
(DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => {
for owner in incoming {
if !current.contains(&owner) {
current.push(owner);
}
}
}
(DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => {
current.retain(|owner| incoming.contains(owner));
}
(DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => {
excluded.retain(|owner| !included.contains(owner));
}
(current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => {
let DeliveryAudience::Owners(included) = current else {
unreachable!("match arm restricts the audience variant")
};
excluded.retain(|owner| !included.contains(owner));
*current = DeliveryAudience::AllExcept(excluded);
}
}
}
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 sort_scoped_records<N: Network>(
records: Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)>,
) -> Vec<(ReactiveInputRecord<N>, DeliveryAudience, DeliveryScope)> {
let mut indexed: Vec<_> = records.into_iter().enumerate().collect();
indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record));
indexed
.into_iter()
.map(|(_, scoped_record)| scoped_record)
.collect()
}
fn record_sort_key<N: Network>(index: usize, record: &ReactiveInputRecord<N>) -> RecordSortKey {
if let Some((block, _)) = reorg_signal_block(record) {
return RecordSortKey {
class: 0,
block_number: block.number,
record_class: 0,
transaction_index: record.context.transaction_index.unwrap_or(u64::MAX),
log_index: record.context.log_index.unwrap_or(u64::MAX),
original_index: index,
};
}
if is_canonical_status(&record.context.chain_status)
&& let Some(block) = record.context.block.as_ref()
{
let (record_class, transaction_index, log_index) = match &record.input {
ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0),
ReactiveInput::Log(log) if !log.removed => (
1,
log.transaction_index
.or(record.context.transaction_index)
.unwrap_or(u64::MAX),
log.log_index
.or(record.context.log_index)
.unwrap_or(u64::MAX),
),
ReactiveInput::Log(_)
| ReactiveInput::PendingTxHash(_)
| ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX),
};
return RecordSortKey {
class: 1,
block_number: block.number,
record_class,
transaction_index,
log_index,
original_index: index,
};
}
RecordSortKey {
class: 2,
block_number: 0,
record_class: 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,
record_class: u8,
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(),
})
}
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[non_exhaustive]
pub enum SubscriberCapability {
Logs,
BlockHeaders,
FullBlocks,
PendingTransactionHashes,
PendingTransactions,
HistoricalBackfill,
Live,
DurableReplay,
OwnerScopedDelivery,
DynamicInterests,
ExplicitReorgs,
FinalityUpdates,
Barriers,
Preconfirmations,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SubscriberCapabilities {
supported: BTreeSet<SubscriberCapability>,
}
impl SubscriberCapabilities {
pub fn new(capabilities: impl IntoIterator<Item = SubscriberCapability>) -> Self {
Self {
supported: capabilities.into_iter().collect(),
}
}
pub fn supports(&self, capability: SubscriberCapability) -> bool {
self.supported.contains(&capability)
}
pub fn iter(&self) -> impl Iterator<Item = SubscriberCapability> + '_ {
self.supported.iter().copied()
}
pub fn supports_live(&self) -> bool {
self.supports(SubscriberCapability::Live)
}
pub fn supports_durable_replay(&self) -> bool {
self.supports(SubscriberCapability::DurableReplay)
}
pub fn supports_explicit_reorgs(&self) -> bool {
self.supports(SubscriberCapability::ExplicitReorgs)
}
}
impl FromIterator<SubscriberCapability> for SubscriberCapabilities {
fn from_iter<T: IntoIterator<Item = SubscriberCapability>>(iter: T) -> Self {
Self::new(iter)
}
}
pub trait EventSubscriber<N: Network = Ethereum>: Send {
fn chain_id(&self) -> Option<u64> {
None
}
fn capabilities(&self) -> SubscriberCapabilities {
SubscriberCapabilities::default()
}
fn register_interests(
&mut self,
interests: &[ReactiveInterest<N>],
) -> SubscriberOperation<'_, ()>;
fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>;
fn restore_position(
&mut self,
_position: &SubscriberResumePosition,
) -> Result<(), SubscriberError> {
Ok(())
}
fn acknowledge_delivery(
&mut self,
_token: SubscriberDeliveryToken,
) -> SubscriberOperation<'_, ()> {
Box::pin(async { Ok(()) })
}
}
pub type SubscriberOperation<'a, T> =
Pin<Box<dyn Future<Output = Result<T, SubscriberError>> + Send + 'a>>;
pub type SubscriberNextBatch<'a, N> = Pin<
Box<dyn Future<Output = Result<Option<ReactiveInputBatch<N>>, SubscriberError>> + Send + 'a>,
>;
pub type SubscriberNextScopedBatch<'a, N> = Pin<
Box<dyn Future<Output = Result<Option<SubscriberInputBatch<N>>, SubscriberError>> + Send + 'a>,
>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum SubscriberMode {
#[default]
Auto,
PubSub,
Polling,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FlashblocksAdapter {
NativeSubscriptions,
PendingStatePolling,
}
fn flashblocks_adapter(chain_id: u64) -> Option<FlashblocksAdapter> {
match chain_id {
8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions),
10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling),
_ => None,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscriberConfig {
pub preconfirmations: PreconfirmationMode,
pub canonical_head_poll_interval: Duration,
pub canonical_head_request_timeout: Duration,
pub flashblock_poll_interval: Duration,
pub max_consecutive_flashblock_poll_failures: usize,
pub max_pending_transaction_receipts_per_tick: usize,
pub max_flashblock_rpc_requests_per_second: usize,
pub hydrate_pending_transactions: bool,
pub verify_log_block_context: bool,
pub max_batch_size: usize,
pub max_log_addresses_per_subscription: usize,
pub max_pending_records: usize,
pub max_pending_backfills: usize,
pub max_backfill_log_bytes: usize,
pub max_reconcile_requests_in_flight: usize,
pub reconnect: SubscriberReconnectConfig,
}
impl Default for SubscriberConfig {
fn default() -> Self {
Self {
preconfirmations: PreconfirmationMode::Disabled,
canonical_head_poll_interval: Duration::from_millis(500),
canonical_head_request_timeout: Duration::from_secs(3),
flashblock_poll_interval: Duration::from_millis(250),
max_consecutive_flashblock_poll_failures: 10,
max_pending_transaction_receipts_per_tick: 32,
max_flashblock_rpc_requests_per_second: 40,
hydrate_pending_transactions: false,
verify_log_block_context: false,
max_batch_size: 1024,
max_log_addresses_per_subscription: 1024,
max_pending_records: 16_384,
max_pending_backfills: 4_096,
max_backfill_log_bytes: 64 * 1024 * 1024,
max_reconcile_requests_in_flight: 8,
reconnect: SubscriberReconnectConfig::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FlashblocksDelivery {
NativeSubscriptions,
PendingStatePolling,
#[cfg(feature = "raw-flashblocks-json")]
ExternalUpdates,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FlashblocksRpcMetrics {
capability_requests: u64,
provider_pair_chain_requests: u64,
canonical_head_requests: u64,
pending_block_requests: u64,
pending_log_requests: u64,
pending_receipt_requests: u64,
pending_receipts_completed: u64,
pending_receipts_unavailable: u64,
failed_requests: u64,
raced_samples: u64,
}
impl FlashblocksRpcMetrics {
pub const fn capability_requests(self) -> u64 {
self.capability_requests
}
pub const fn provider_pair_chain_requests(self) -> u64 {
self.provider_pair_chain_requests
}
pub const fn canonical_head_requests(self) -> u64 {
self.canonical_head_requests
}
pub const fn pending_block_requests(self) -> u64 {
self.pending_block_requests
}
pub const fn pending_log_requests(self) -> u64 {
self.pending_log_requests
}
pub const fn pending_receipt_requests(self) -> u64 {
self.pending_receipt_requests
}
pub const fn pending_receipts_completed(self) -> u64 {
self.pending_receipts_completed
}
pub const fn pending_receipts_unavailable(self) -> u64 {
self.pending_receipts_unavailable
}
pub const fn failed_requests(self) -> u64 {
self.failed_requests
}
pub const fn raced_samples(self) -> u64 {
self.raced_samples
}
pub const fn total_requests(self) -> u64 {
self.capability_requests
.saturating_add(self.provider_pair_chain_requests)
.saturating_add(self.canonical_head_requests)
.saturating_add(self.pending_block_requests)
.saturating_add(self.pending_log_requests)
.saturating_add(self.pending_receipt_requests)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FlashblocksPreflight {
chain_id: u64,
provider: ProviderRef,
delivery: FlashblocksDelivery,
pending_log_subscriptions: usize,
pending_log_filters: usize,
advertised_capabilities: Option<serde_json::Value>,
}
impl FlashblocksPreflight {
pub const fn chain_id(&self) -> u64 {
self.chain_id
}
pub const fn provider(&self) -> &ProviderRef {
&self.provider
}
pub const fn delivery(&self) -> FlashblocksDelivery {
self.delivery
}
pub const fn pending_log_subscriptions(&self) -> usize {
self.pending_log_subscriptions
}
pub const fn pending_log_filters(&self) -> usize {
self.pending_log_filters
}
pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> {
self.advertised_capabilities.as_ref()
}
}
#[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>,
retained_anchor: Option<BlockRef>,
}
impl SubscriberBackfill {
pub fn range(from_block: u64, to_block: u64) -> Self {
Self {
from_block,
to_block: Some(to_block),
retained_anchor: None,
}
}
pub fn from_block(from_block: u64) -> Self {
Self {
from_block,
to_block: None,
retained_anchor: None,
}
}
pub fn from_canonical_block(block: BlockRef) -> Self {
Self {
from_block: block.number,
to_block: None,
retained_anchor: Some(block),
}
}
pub fn from_canonical_block_through(
block: BlockRef,
to_block: u64,
) -> Result<Self, SubscriberError> {
if to_block < block.number {
return Err(SubscriberError::InvalidConfig(
"inclusive backfill upper bound precedes its retained anchor",
));
}
Ok(Self {
from_block: block.number,
to_block: Some(to_block),
retained_anchor: Some(block),
})
}
pub fn after_canonical_block(block: BlockRef) -> Result<Self, SubscriberError> {
Self::after_canonical_block_inner(block, None)
}
pub fn after_canonical_block_through(
block: BlockRef,
to_block: u64,
) -> Result<Self, SubscriberError> {
if to_block < block.number {
return Err(SubscriberError::InvalidConfig(
"exclusive backfill upper bound precedes its retained baseline",
));
}
Self::after_canonical_block_inner(block, Some(to_block))
}
fn after_canonical_block_inner(
block: BlockRef,
to_block: Option<u64>,
) -> Result<Self, SubscriberError> {
let from_block = block
.number
.checked_add(1)
.ok_or(SubscriberError::InvalidConfig(
"cannot construct an exclusive backfill after block u64::MAX",
))?;
Ok(Self {
from_block,
to_block,
retained_anchor: Some(block),
})
}
pub fn start_block(&self) -> u64 {
self.from_block
}
pub fn end_block(&self) -> Option<u64> {
self.to_block
}
pub fn retained_anchor(&self) -> Option<&BlockRef> {
self.retained_anchor.as_ref()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SubscriberOwnerEpoch {
owner: HandlerId,
sequence: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriberInputScope {
Canonical {
owners: Vec<SubscriberOwnerEpoch>,
},
CanonicalResidual {
owners: Vec<SubscriberOwnerEpoch>,
excluded: Vec<HandlerId>,
},
OwnerOnly {
owners: Vec<SubscriberOwnerEpoch>,
},
OwnerOnlyHandlers {
owners: Vec<HandlerId>,
},
Preconfirmed,
}
impl SubscriberInputScope {
pub fn owners(&self) -> &[SubscriberOwnerEpoch] {
match self {
Self::Canonical { owners }
| Self::CanonicalResidual { owners, .. }
| Self::OwnerOnly { owners } => owners,
Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[],
}
}
pub const fn is_canonical(&self) -> bool {
matches!(
self,
Self::Canonical { .. } | Self::CanonicalResidual { .. }
)
}
pub const fn is_preconfirmed(&self) -> bool {
matches!(self, Self::Preconfirmed)
}
}
#[derive(Clone, Debug)]
pub struct SubscriberInputRecord<N: Network = Ethereum> {
record: ReactiveInputRecord<N>,
scope: SubscriberInputScope,
}
impl<N: Network> SubscriberInputRecord<N> {
pub const fn record(&self) -> &ReactiveInputRecord<N> {
&self.record
}
pub const fn scope(&self) -> &SubscriberInputScope {
&self.scope
}
pub fn into_record(self) -> ReactiveInputRecord<N> {
self.record
}
}
impl<N: Network> std::ops::Deref for SubscriberInputRecord<N> {
type Target = ReactiveInputRecord<N>;
fn deref(&self) -> &Self::Target {
&self.record
}
}
#[derive(Clone, Debug)]
pub struct SubscriberInputBatch<N: Network = Ethereum> {
records: Vec<SubscriberInputRecord<N>>,
chain_id: Option<u64>,
chain_controls: Vec<ChainControl>,
preconfirmation_invalidated: bool,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum SubscriberDriverPoll<C, N: Network = Ethereum> {
Control(C),
Batch(Option<SubscriberInputBatch<N>>),
}
impl<N: Network> SubscriberInputBatch<N> {
pub fn records(&self) -> &[SubscriberInputRecord<N>] {
&self.records
}
pub fn into_records(self) -> Vec<SubscriberInputRecord<N>> {
self.records
}
pub fn chain_controls(&self) -> &[ChainControl] {
&self.chain_controls
}
pub const fn preconfirmation_invalidated(&self) -> bool {
self.preconfirmation_invalidated
}
pub fn into_reactive_batch(self) -> ReactiveInputBatch<N> {
let chain_id = self.chain_id;
let chain_controls = self.chain_controls;
let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope(
self.records.into_iter().map(|scoped| {
let source = scoped.record.context.source;
let (audience, delivery_scope) = match scoped.scope {
SubscriberInputScope::Canonical { .. } => (
DeliveryAudience::All,
if source == InputSource::Backfill {
DeliveryScope::CanonicalProgress
} else {
DeliveryScope::Canonical
},
),
SubscriberInputScope::CanonicalResidual { excluded, .. } => (
DeliveryAudience::AllExcept(excluded),
if source == InputSource::Backfill {
DeliveryScope::CanonicalProgress
} else {
DeliveryScope::Canonical
},
),
SubscriberInputScope::OwnerOnly { owners } => {
let mut handler_ids = Vec::with_capacity(owners.len());
for epoch in owners {
if !handler_ids.contains(epoch.owner()) {
handler_ids.push(epoch.owner().clone());
}
}
(
DeliveryAudience::Owners(handler_ids),
DeliveryScope::OwnerCatchup,
)
}
SubscriberInputScope::OwnerOnlyHandlers { owners } => (
DeliveryAudience::Owners(owners),
DeliveryScope::OwnerCatchup,
),
SubscriberInputScope::Preconfirmed => {
(DeliveryAudience::All, DeliveryScope::Preconfirmed)
}
};
(scoped.record, audience, delivery_scope)
}),
)
.with_chain_controls(chain_controls);
if let Some(chain_id) = chain_id {
batch = batch.with_chain_id(chain_id);
}
batch
}
}
impl SubscriberOwnerEpoch {
pub const fn owner(&self) -> &HandlerId {
&self.owner
}
pub const fn sequence(&self) -> u64 {
self.sequence
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriberOwnerStart {
Live,
PostBlock(BlockRef),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubscriberOwnerState {
Staged,
Active,
Removing,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscriberOwnerProgress {
owner: SubscriberOwnerEpoch,
through: BlockRef,
}
impl SubscriberOwnerProgress {
pub const fn owner(&self) -> &SubscriberOwnerEpoch {
&self.owner
}
pub const fn through(&self) -> &BlockRef {
&self.through
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SubscriberOwnerError {
#[error(transparent)]
Subscriber(#[from] SubscriberError),
#[error("subscriber interest owner `{0}` is already registered")]
AlreadyRegistered(HandlerId),
#[error("post-block subscriber baseline {0} has no following block")]
PostBlockOverflow(u64),
#[error("subscriber owner epoch sequence exhausted")]
EpochExhausted,
#[error("subscriber owner epoch is not staged")]
NotStaged,
#[error("subscriber owner was staged live-only and has no catch-up baseline")]
MissingBaseline,
#[error("post-block subscriber owners support log interests only")]
UnsupportedPostBlockInterest,
#[error("subscriber reconcile target block {0} was not found")]
BlockUnavailable(u64),
#[error(
"subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}"
)]
BlockMismatch {
expected_number: u64,
expected_hash: B256,
actual_number: u64,
actual_hash: B256,
},
#[error("subscriber reconcile target block {target} precedes current owner position {current}")]
ProgressRegression {
current: u64,
target: u64,
},
#[error(
"subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}"
)]
ProgressConflict {
number: u64,
current_hash: B256,
target_hash: B256,
},
#[error("subscriber reconcile returned an invalid catch-up log: {0}")]
InvalidBackfillLog(&'static str),
}
pub trait InterestOwnerSubscriber<N: Network = Ethereum>: EventSubscriber<N> {
fn upsert_interest_owners(
&mut self,
_owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> SubscriberOperation<'_, ()> {
Box::pin(async {
Err(SubscriberError::Unsupported(
"subscriber does not implement atomic bulk owner upsert",
))
})
}
fn replace_interest_owners(
&mut self,
_owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> SubscriberOperation<'_, ()> {
Box::pin(async {
Err(SubscriberError::Unsupported(
"subscriber does not implement atomic exact owner replacement",
))
})
}
fn replace_interest_owners_with_global_backfill(
&mut self,
_owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
_backfill: SubscriberBackfill,
) -> SubscriberOperation<'_, ()> {
Box::pin(async {
Err(SubscriberError::Unsupported(
"subscriber does not implement atomic owner replacement with global backfill",
))
})
}
fn add_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
) -> SubscriberOperation<'_, ()>;
fn add_interest_owner_with_backfill(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: SubscriberBackfill,
) -> SubscriberOperation<'_, ()>;
fn add_interest_owner_with_canonical_catchup(
&mut self,
_owner: HandlerId,
_interests: &[ReactiveInterest<N>],
_retained: BlockRef,
) -> SubscriberOperation<'_, ()> {
Box::pin(async {
Err(SubscriberError::Unsupported(
"subscriber does not implement coordinated canonical owner catch-up",
))
})
}
fn remove_interest_owner(
&mut self,
owner: &HandlerId,
) -> SubscriberOperation<'_, 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,
pending_acknowledgement: Option<PendingAcknowledgement<N>>,
pending_checkpoint: Option<PendingCheckpoint<N>>,
last_checkpoint_block: Option<DurableCheckpointBlock>,
last_checkpoint_delivery_token: Option<SubscriberDeliveryToken>,
last_checkpoint_delivery_witness: Option<B256>,
last_subscriber_checkpoint: Option<SubscriberCheckpoint>,
checkpoint_identity: Option<DurableCheckpointIdentity>,
}
struct PendingAcknowledgement<N: Network> {
token: SubscriberDeliveryToken,
report: ReactiveBatchReport<N>,
}
struct PendingCheckpoint<N: Network> {
metadata: DurableCheckpointMetadata,
delivery_token: Option<SubscriberDeliveryToken>,
report: ReactiveBatchReport<N>,
saved_to: Option<PathBuf>,
staged_generation: u64,
}
struct CheckpointStage<N: Network> {
incoming_block: Option<DurableCheckpointBlock>,
delivery_token: Option<SubscriberDeliveryToken>,
delivery_witness: Option<B256>,
subscriber_checkpoint: Option<SubscriberCheckpoint>,
staged_generation: u64,
report: ReactiveBatchReport<N>,
}
struct DurableResumePlan {
runtime: DurableRuntimeRestorePlan,
position: SubscriberResumePosition,
delivery_witness: Option<B256>,
}
enum HandlerRegistrationCatchup {
LiveOnly,
OwnerBackfill(SubscriberBackfill),
CoordinatedCanonical(BlockRef),
}
const DELIVERY_WITNESS_VERSION: u32 = 1;
const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness";
#[derive(serde::Serialize)]
struct DeliveryWitnessEnvelope<'a> {
version: u32,
chain_id: Option<u64>,
records: Vec<DeliveryRecordWitness<'a>>,
chain_controls: &'a [ChainControl],
subscriber_checkpoint: Option<&'a [u8]>,
payload_commitment: Option<B256>,
}
#[derive(serde::Serialize)]
struct DeliveryRecordWitness<'a> {
identity: ReactiveInputIdentity,
context: &'a ReactiveContext,
audience: &'a DeliveryAudience,
scope: DeliveryScope,
payload: DeliveryPayloadWitness<'a>,
}
#[derive(serde::Serialize)]
enum DeliveryPayloadWitness<'a> {
Log {
address: Address,
topics: &'a [B256],
data: &'a Bytes,
block_hash: Option<B256>,
block_number: Option<u64>,
block_timestamp: Option<u64>,
transaction_hash: Option<B256>,
transaction_index: Option<u64>,
log_index: Option<u64>,
removed: bool,
},
IdentityCommitted,
}
fn durable_delivery_witness<N: Network>(
batch: &ReactiveInputBatch<N>,
) -> Result<B256, ReactiveEngineError> {
let requires_payload_commitment = batch.records.iter().any(|record| {
matches!(
&record.input,
ReactiveInput::BlockHeader(_)
| ReactiveInput::FullBlock(_)
| ReactiveInput::PendingTx(_)
)
});
if requires_payload_commitment && batch.payload_commitment.is_none() {
return Err(ReactiveEngineError::MissingPayloadCommitment);
}
let records = batch
.records
.iter()
.enumerate()
.map(|(index, record)| {
let payload = match &record.input {
ReactiveInput::Log(log) => DeliveryPayloadWitness::Log {
address: log.address(),
topics: log.topics(),
data: &log.inner.data.data,
block_hash: log.block_hash,
block_number: log.block_number,
block_timestamp: log.block_timestamp,
transaction_hash: log.transaction_hash,
transaction_index: log.transaction_index,
log_index: log.log_index,
removed: log.removed,
},
ReactiveInput::BlockHeader(_)
| ReactiveInput::FullBlock(_)
| ReactiveInput::PendingTxHash(_)
| ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted,
};
Ok(DeliveryRecordWitness {
identity: record.validated_identity()?,
context: &record.context,
audience: batch
.record_audience(index)
.expect("enumerated record always has an audience"),
scope: batch
.record_delivery_scope(index)
.expect("enumerated record always has a delivery scope"),
payload,
})
})
.collect::<Result<Vec<_>, ReactiveError>>()?;
let envelope = DeliveryWitnessEnvelope {
version: DELIVERY_WITNESS_VERSION,
chain_id: batch.chain_id,
records,
chain_controls: &batch.chain_controls,
subscriber_checkpoint: batch
.subscriber_checkpoint
.as_ref()
.map(SubscriberCheckpoint::as_bytes),
payload_commitment: batch
.payload_commitment
.as_ref()
.map(SubscriberPayloadCommitment::digest),
};
let encoded = bincode::DefaultOptions::new()
.with_fixint_encoding()
.serialize(&envelope)
.map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?;
let mut witness = Keccak256::new();
witness.update(DELIVERY_WITNESS_DOMAIN);
witness.update(encoded);
Ok(witness.finalize())
}
impl<S, N> ReactiveEngine<S, N>
where
N: Network,
S: EventSubscriber<N>,
{
pub fn new(runtime: ReactiveRuntime<N>, subscriber: S) -> Self {
Self {
runtime,
subscriber,
pending_acknowledgement: None,
pending_checkpoint: None,
last_checkpoint_block: None,
last_checkpoint_delivery_token: None,
last_checkpoint_delivery_witness: None,
last_subscriber_checkpoint: None,
checkpoint_identity: None,
}
}
pub fn into_parts(self) -> Result<(ReactiveRuntime<N>, S), Box<Self>> {
if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() {
return Err(Box::new(self));
}
Ok((self.runtime, self.subscriber))
}
fn durable_resume_plan(
&self,
metadata: &DurableCheckpointMetadata,
) -> Result<DurableResumePlan, ReactiveCheckpointRestoreError> {
if !self.subscriber.capabilities().supports_durable_replay() {
return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
}
self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?;
if !self.runtime.is_pristine_for_checkpoint_restore()
|| self.pending_acknowledgement.is_some()
|| self.pending_checkpoint.is_some()
|| self.last_checkpoint_block.is_some()
|| self.last_checkpoint_delivery_token.is_some()
|| self.last_checkpoint_delivery_witness.is_some()
|| self.last_subscriber_checkpoint.is_some()
|| self.checkpoint_identity.is_some()
{
return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
}
let block = BlockRef {
number: metadata.block.number,
hash: metadata.block.hash,
parent_hash: metadata.block.parent_hash,
timestamp: metadata.block.timestamp,
};
let runtime = match metadata.runtime_checkpoint.as_deref() {
Some(bytes) => self
.runtime
.plan_durable_checkpoint_restore(bytes, &block)?,
None => DurableRuntimeRestorePlan {
checkpoint: None,
fallback_history: (self.runtime.config.journal_depth > 0)
.then_some(block)
.into_iter()
.collect(),
},
};
let delivery_token = metadata
.delivery_token
.clone()
.map(SubscriberDeliveryToken::new);
let subscriber_checkpoint = metadata
.subscriber_checkpoint
.clone()
.map(SubscriberCheckpoint::new);
let position = SubscriberResumePosition::new(
metadata.identity.chain_id,
block,
runtime.canonical_history(),
delivery_token,
subscriber_checkpoint,
);
Ok(DurableResumePlan {
runtime,
position,
delivery_witness: metadata.delivery_witness,
})
}
pub fn preview_durable_resume_position(
&self,
metadata: &DurableCheckpointMetadata,
) -> Result<SubscriberResumePosition, ReactiveCheckpointRestoreError> {
Ok(self.durable_resume_plan(metadata)?.position)
}
pub fn resume_from_durable_checkpoint(
&mut self,
metadata: &DurableCheckpointMetadata,
) -> Result<(), ReactiveCheckpointRestoreError> {
let plan = self.durable_resume_plan(metadata)?;
let prior_runtime = self.runtime.checkpoint_state();
let DurableResumePlan {
runtime,
position,
delivery_witness,
} = plan;
self.runtime.apply_durable_checkpoint_restore(runtime);
self.runtime.coverage_head = Some(position.coverage_head);
if let Err(error) = self.subscriber.restore_position(&position) {
self.runtime.restore_state(prior_runtime);
return Err(ReactiveCheckpointRestoreError::Subscriber(error));
}
if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) {
self.runtime.restore_state(prior_runtime);
return Err(error);
}
self.last_checkpoint_block = Some(metadata.block.clone());
self.last_checkpoint_delivery_token = position.delivery_token;
self.last_checkpoint_delivery_witness = delivery_witness;
self.last_subscriber_checkpoint = position.subscriber_checkpoint;
self.checkpoint_identity = Some(metadata.identity.clone());
Ok(())
}
pub fn restore_durable_checkpoint(
&mut self,
cache: &mut EvmCache,
loaded: LoadedDurableCheckpoint,
expected: &DurableCheckpointIdentity,
) -> Result<DurableCheckpointMetadata, ReactiveCheckpointRestoreError> {
if !self.subscriber.capabilities().supports_durable_replay() {
return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable);
}
self.ensure_subscriber_restore_chain(expected.chain_id)?;
if !self.runtime.is_pristine_for_checkpoint_restore()
|| self.pending_acknowledgement.is_some()
|| self.pending_checkpoint.is_some()
|| self.last_checkpoint_block.is_some()
|| self.last_checkpoint_delivery_token.is_some()
|| self.last_checkpoint_delivery_witness.is_some()
|| self.last_subscriber_checkpoint.is_some()
|| self.checkpoint_identity.is_some()
{
return Err(ReactiveCheckpointRestoreError::ActiveRuntime);
}
let prior_cache = EvmCacheStateSnapshot::capture(cache);
let metadata = loaded.restore_into(cache, expected)?;
if let Err(error) = self.resume_from_durable_checkpoint(&metadata) {
prior_cache.restore(cache);
return Err(error);
}
Ok(metadata)
}
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 adopt_canonical_baseline(
&mut self,
cache: &EvmCache,
baseline: ReactiveCanonicalBaseline,
) -> Result<(), ReactiveEngineError> {
if self.pending_acknowledgement.is_some()
|| self.pending_checkpoint.is_some()
|| self.last_checkpoint_block.is_some()
|| self.last_checkpoint_delivery_token.is_some()
|| self.last_checkpoint_delivery_witness.is_some()
|| self.last_subscriber_checkpoint.is_some()
|| self.checkpoint_identity.is_some()
{
return Err(ReactiveBaselineError::ActiveRuntime.into());
}
self.runtime
.validate_canonical_baseline_adoption(baseline.block)?;
if baseline.chain_id != cache.chain_id() {
return Err(ReactiveBaselineError::CacheChainMismatch {
baseline_chain_id: baseline.chain_id,
cache_chain_id: cache.chain_id(),
}
.into());
}
self.ensure_subscriber_chain(cache)?;
let exact_selector = BlockId::from((baseline.block.hash, Some(true)));
let context_matches = cache.block_number() == Some(baseline.block.number)
&& baseline
.block
.timestamp
.is_none_or(|timestamp| cache.timestamp() == Some(timestamp));
if cache.block() != exact_selector || !context_matches {
return Err(ReactiveBaselineError::CacheBlockMismatch {
number: baseline.block.number,
hash: baseline.block.hash,
}
.into());
}
self.runtime.adopt_canonical_baseline(baseline.block)?;
Ok(())
}
pub fn next_batch(
&mut self,
cache: &EvmCache,
) -> Result<SubscriberNextBatch<'_, N>, ReactiveEngineError> {
if self.pending_checkpoint.is_some() {
return Err(ReactiveEngineError::PendingCheckpointCommit);
}
if self.pending_acknowledgement.is_some() {
return Err(ReactiveEngineError::PendingAcknowledgementCommit);
}
self.ensure_subscriber_chain(cache)?;
Ok(self.subscriber.next_batch())
}
pub fn ingest_batch(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
self.ensure_raw_ingest_is_safe(cache, &batch)?;
Ok(self.runtime.ingest_batch(cache, batch)?)
}
pub fn ingest_batch_with_resync(
&mut self,
cache: &mut EvmCache,
batch: ReactiveInputBatch<N>,
) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
self.ensure_raw_ingest_is_safe(cache, &batch)?;
Ok(self.runtime.ingest_batch_with_resync(cache, batch)?)
}
fn ensure_raw_ingest_is_safe(
&self,
cache: &EvmCache,
batch: &ReactiveInputBatch<N>,
) -> Result<(), ReactiveEngineError> {
if self.pending_checkpoint.is_some() {
return Err(ReactiveEngineError::PendingCheckpointCommit);
}
if self.pending_acknowledgement.is_some() {
return Err(ReactiveEngineError::PendingAcknowledgementCommit);
}
if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() {
return Err(ReactiveEngineError::UncommittedDeliveryMetadata);
}
self.ensure_subscriber_chain(cache)?;
Ok(())
}
fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> {
if let Some(subscriber_chain_id) = self.subscriber.chain_id()
&& subscriber_chain_id != cache.chain_id()
{
return Err(ReactiveEngineError::SubscriberChainMismatch {
subscriber_chain_id,
cache_chain_id: cache.chain_id(),
});
}
Ok(())
}
fn ensure_subscriber_restore_chain(
&self,
checkpoint_chain_id: u64,
) -> Result<(), ReactiveCheckpointRestoreError> {
if let Some(subscriber_chain_id) = self.subscriber.chain_id()
&& subscriber_chain_id != checkpoint_chain_id
{
return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch {
subscriber_chain_id,
checkpoint_chain_id,
});
}
Ok(())
}
pub async fn next_ingest(
&mut self,
cache: &mut EvmCache,
) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
self.ensure_subscriber_chain(cache)?;
if self.pending_checkpoint.is_some() {
return Err(ReactiveEngineError::PendingCheckpointCommit);
}
if self.pending_acknowledgement.is_some() {
return self.commit_pending_acknowledgement().await.map(Some);
}
let batch = self.subscriber.next_batch().await?;
self.ensure_subscriber_chain(cache)?;
let Some(mut batch) = batch else {
return Ok(None);
};
let delivery_token = batch.take_delivery_token();
let report = self.runtime.ingest_batch(cache, batch)?;
self.stage_or_return_acknowledgement(delivery_token, report)
.await
}
pub async fn next_ingest_with_resync(
&mut self,
cache: &mut EvmCache,
) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
self.ensure_subscriber_chain(cache)?;
if self.pending_checkpoint.is_some() {
return Err(ReactiveEngineError::PendingCheckpointCommit);
}
if self.pending_acknowledgement.is_some() {
return self.commit_pending_acknowledgement().await.map(Some);
}
let batch = self.subscriber.next_batch().await?;
self.ensure_subscriber_chain(cache)?;
let Some(mut batch) = batch else {
return Ok(None);
};
let delivery_token = batch.take_delivery_token();
let report = self.runtime.ingest_batch_with_resync(cache, batch)?;
self.stage_or_return_acknowledgement(delivery_token, report)
.await
}
pub async fn next_ingest_checkpointed(
&mut self,
cache: &mut EvmCache,
store: &DurableCheckpointStore,
identity: &DurableCheckpointIdentity,
) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
if !self.subscriber.capabilities().supports_durable_replay() {
return Err(ReactiveEngineError::SubscriberNotDurable);
}
self.ensure_subscriber_chain(cache)?;
if self.pending_acknowledgement.is_some() {
return Err(ReactiveEngineError::PendingAcknowledgementCommit);
}
self.ensure_checkpoint_identity(cache, identity)?;
if self.pending_checkpoint.is_some() {
return self.commit_pending_checkpoint(cache, store).await.map(Some);
}
let batch = self.subscriber.next_batch().await?;
self.ensure_subscriber_chain(cache)?;
let Some(mut batch) = batch else {
return Ok(None);
};
if batch_preconfirmation(&batch)?.is_some() {
return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
}
self.runtime.discard_preconfirmed_branch(cache);
let delivery_witness = batch
.delivery_token()
.map(|_| durable_delivery_witness(&batch))
.transpose()?;
let delivery_token = batch.take_delivery_token();
let subscriber_checkpoint = batch.take_subscriber_checkpoint();
if let (Some(replay_token), Some(committed_token)) = (
delivery_token.as_ref(),
self.last_checkpoint_delivery_token.as_ref(),
) && replay_token == committed_token
{
let committed_witness = self
.last_checkpoint_delivery_witness
.ok_or(ReactiveEngineError::MissingReplayWitness)?;
if delivery_witness != Some(committed_witness) {
return Err(ReactiveEngineError::ReplayDeliveryMismatch);
}
self.subscriber
.acknowledge_delivery(replay_token.clone())
.await
.map_err(ReactiveEngineError::Acknowledgement)?;
return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
}
self.ensure_checkpointable_reorgs(&batch)?;
let incoming_block = latest_canonical_batch_block(&batch);
let cache_state = EvmCacheStateSnapshot::capture(cache);
let runtime_state = self.runtime.checkpoint_state();
let report = match self.runtime.ingest_batch_direct(cache, batch) {
Ok(report) => report,
Err(error) => {
cache_state.restore(cache);
self.runtime.restore_transaction_state(runtime_state);
return Err(error.into());
}
};
let reports = report.reports.clone();
let stage = CheckpointStage {
incoming_block,
delivery_token,
delivery_witness,
subscriber_checkpoint,
staged_generation: cache.snapshot_generation(),
report,
};
if let Err(error) = self.stage_checkpoint(identity, stage) {
cache_state.restore(cache);
self.runtime.restore_transaction_state(runtime_state);
return Err(error);
}
self.runtime.dispatch_reports(&reports);
self.commit_pending_checkpoint(cache, store).await.map(Some)
}
pub async fn next_ingest_with_resync_checkpointed(
&mut self,
cache: &mut EvmCache,
store: &DurableCheckpointStore,
identity: &DurableCheckpointIdentity,
) -> Result<Option<CheckpointedIngest<N>>, ReactiveEngineError> {
if !self.subscriber.capabilities().supports_durable_replay() {
return Err(ReactiveEngineError::SubscriberNotDurable);
}
self.ensure_subscriber_chain(cache)?;
if self.pending_acknowledgement.is_some() {
return Err(ReactiveEngineError::PendingAcknowledgementCommit);
}
self.ensure_checkpoint_identity(cache, identity)?;
if self.pending_checkpoint.is_some() {
return self.commit_pending_checkpoint(cache, store).await.map(Some);
}
let batch = self.subscriber.next_batch().await?;
self.ensure_subscriber_chain(cache)?;
let Some(mut batch) = batch else {
return Ok(None);
};
if batch_preconfirmation(&batch)?.is_some() {
return Err(ReactiveEngineError::PreconfirmationNotCheckpointable);
}
self.runtime.discard_preconfirmed_branch(cache);
let delivery_witness = batch
.delivery_token()
.map(|_| durable_delivery_witness(&batch))
.transpose()?;
let delivery_token = batch.take_delivery_token();
let subscriber_checkpoint = batch.take_subscriber_checkpoint();
if let (Some(replay_token), Some(committed_token)) = (
delivery_token.as_ref(),
self.last_checkpoint_delivery_token.as_ref(),
) && replay_token == committed_token
{
let committed_witness = self
.last_checkpoint_delivery_witness
.ok_or(ReactiveEngineError::MissingReplayWitness)?;
if delivery_witness != Some(committed_witness) {
return Err(ReactiveEngineError::ReplayDeliveryMismatch);
}
self.subscriber
.acknowledge_delivery(replay_token.clone())
.await
.map_err(ReactiveEngineError::Acknowledgement)?;
return Ok(Some(CheckpointedIngest::ReplayAcknowledged));
}
self.ensure_checkpointable_reorgs(&batch)?;
let incoming_block = latest_canonical_batch_block(&batch);
let cache_state = EvmCacheStateSnapshot::capture(cache);
let runtime_state = self.runtime.checkpoint_state();
let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) {
Ok(report) => report,
Err(error) => {
cache_state.restore(cache);
self.runtime.restore_transaction_state(runtime_state);
return Err(error.into());
}
};
let reports = report.reports.clone();
let stage = CheckpointStage {
incoming_block,
delivery_token,
delivery_witness,
subscriber_checkpoint,
staged_generation: cache.snapshot_generation(),
report,
};
if let Err(error) = self.stage_checkpoint(identity, stage) {
cache_state.restore(cache);
self.runtime.restore_transaction_state(runtime_state);
return Err(error);
}
self.runtime.dispatch_reports(&reports);
self.commit_pending_checkpoint(cache, store).await.map(Some)
}
fn stage_checkpoint(
&mut self,
identity: &DurableCheckpointIdentity,
stage: CheckpointStage<N>,
) -> Result<(), ReactiveEngineError> {
let CheckpointStage {
incoming_block,
delivery_token,
delivery_witness,
subscriber_checkpoint,
staged_generation,
report,
} = stage;
if delivery_token.is_some() != delivery_witness.is_some() {
return Err(ReactiveEngineError::DeliveryWitness(
"delivery token and witness must be staged together".into(),
));
}
let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?;
let block = self
.runtime
.last_canonical_block()
.map(|block| DurableCheckpointBlock {
number: block.number,
hash: block.hash,
parent_hash: block.parent_hash,
timestamp: block.timestamp,
})
.or(incoming_block)
.or_else(|| self.last_checkpoint_block.clone())
.ok_or(ReactiveEngineError::MissingCheckpointBlock)?;
let metadata = DurableCheckpointMetadata {
identity: identity.clone(),
block,
delivery_token: delivery_token
.as_ref()
.or(self.last_checkpoint_delivery_token.as_ref())
.map(|token| token.as_bytes().to_vec()),
delivery_witness: if delivery_token.is_some() {
delivery_witness
} else {
self.last_checkpoint_delivery_witness
},
subscriber_checkpoint: subscriber_checkpoint
.as_ref()
.or(self.last_subscriber_checkpoint.as_ref())
.map(|checkpoint| checkpoint.as_bytes().to_vec()),
runtime_checkpoint: Some(runtime_checkpoint),
};
self.pending_checkpoint = Some(PendingCheckpoint {
metadata,
delivery_token,
report,
saved_to: None,
staged_generation,
});
Ok(())
}
fn ensure_checkpointable_reorgs(
&self,
batch: &ReactiveInputBatch<N>,
) -> Result<(), ReactiveEngineError> {
let state = CanonicalSequenceState::new(
self.runtime
.journal
.iter()
.map(|entry| entry.block)
.collect(),
self.runtime.coverage_head,
self.runtime.safe_head,
self.runtime.finalized_head,
);
match validate_canonical_sequence_internal(
&state,
batch,
CanonicalSequenceValidationPolicy::RequireCompleteRollback,
) {
Ok(_) => Ok(()),
Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()),
Err(CanonicalSequenceError::IncompleteRollback {
common_ancestor,
oldest_retained,
..
}) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal {
common_ancestor,
oldest_journaled: oldest_retained,
journal_depth: self.runtime.config.journal_depth,
}),
}
}
async fn stage_or_return_acknowledgement(
&mut self,
delivery_token: Option<SubscriberDeliveryToken>,
report: ReactiveBatchReport<N>,
) -> Result<Option<ReactiveBatchReport<N>>, ReactiveEngineError> {
let Some(token) = delivery_token else {
return Ok(Some(report));
};
self.pending_acknowledgement = Some(PendingAcknowledgement { token, report });
self.commit_pending_acknowledgement().await.map(Some)
}
async fn commit_pending_acknowledgement(
&mut self,
) -> Result<ReactiveBatchReport<N>, ReactiveEngineError> {
let token = self
.pending_acknowledgement
.as_ref()
.expect("caller checked pending acknowledgement")
.token
.clone();
self.subscriber
.acknowledge_delivery(token)
.await
.map_err(ReactiveEngineError::Acknowledgement)?;
Ok(self
.pending_acknowledgement
.take()
.expect("pending acknowledgement remains until commit")
.report)
}
async fn commit_pending_checkpoint(
&mut self,
cache: &EvmCache,
store: &DurableCheckpointStore,
) -> Result<CheckpointedIngest<N>, ReactiveEngineError> {
let pending = self
.pending_checkpoint
.as_mut()
.expect("caller checked pending checkpoint");
let cache_generation = cache.snapshot_generation();
if cache_generation != pending.staged_generation {
return Err(ReactiveEngineError::PendingCheckpointCacheChanged {
staged_generation: pending.staged_generation,
current_generation: cache_generation,
});
}
if pending.saved_to.as_deref() != Some(store.path()) {
store
.save_async(cache, pending.metadata.clone())
.await
.map_err(ReactiveEngineError::Checkpoint)?;
pending.saved_to = Some(store.path().to_path_buf());
}
if let Some(token) = pending.delivery_token.clone() {
self.subscriber
.acknowledge_delivery(token)
.await
.map_err(ReactiveEngineError::Acknowledgement)?;
}
let pending = self
.pending_checkpoint
.take()
.expect("pending checkpoint remains until commit");
self.last_checkpoint_block = Some(pending.metadata.block);
self.checkpoint_identity = Some(pending.metadata.identity);
self.last_checkpoint_delivery_token = pending
.metadata
.delivery_token
.map(SubscriberDeliveryToken::new);
self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness;
self.last_subscriber_checkpoint = pending
.metadata
.subscriber_checkpoint
.map(SubscriberCheckpoint::new);
Ok(CheckpointedIngest::Applied(pending.report))
}
fn ensure_checkpoint_identity(
&self,
cache: &EvmCache,
identity: &DurableCheckpointIdentity,
) -> Result<(), ReactiveEngineError> {
if identity.chain_id != cache.chain_id() {
return Err(ReactiveEngineError::Checkpoint(
DurableCheckpointError::CacheChainMismatch {
cache_chain_id: cache.chain_id(),
checkpoint_chain_id: identity.chain_id,
},
));
}
if let Some(actual) = self.checkpoint_identity.as_ref()
&& actual != identity
{
return Err(ReactiveEngineError::Checkpoint(
DurableCheckpointError::IdentityMismatch {
expected: identity.clone(),
actual: actual.clone(),
},
));
}
if let Some(pending) = self.pending_checkpoint.as_ref()
&& &pending.metadata.identity != identity
{
return Err(ReactiveEngineError::Checkpoint(
DurableCheckpointError::IdentityMismatch {
expected: identity.clone(),
actual: pending.metadata.identity.clone(),
},
));
}
Ok(())
}
}
fn latest_canonical_batch_block<N: Network>(
batch: &ReactiveInputBatch<N>,
) -> Option<DurableCheckpointBlock> {
let record_block = batch
.records()
.iter()
.enumerate()
.filter(|(index, _)| {
batch
.record_delivery_scope(*index)
.is_some_and(DeliveryScope::advances_canonical_state)
})
.filter_map(|(_, record)| canonical_record_block(record))
.max_by_key(|block| block.number)
.cloned();
let control_block = batch
.chain_controls()
.iter()
.filter_map(|control| match control {
ChainControl::Reorg {
common_ancestor, ..
} => Some(common_ancestor),
ChainControl::Barrier {
block: Some(block), ..
}
| ChainControl::CanonicalProgress(block) => Some(block),
ChainControl::Safe(_)
| ChainControl::Finalized(_)
| ChainControl::Barrier { block: None, .. } => None,
})
.max_by_key(|block| block.number)
.cloned();
record_block
.into_iter()
.chain(control_block)
.max_by_key(|block| block.number)
.map(|block| DurableCheckpointBlock {
number: block.number,
hash: block.hash,
parent_hash: block.parent_hash,
timestamp: block.timestamp,
})
}
impl<S, N> ReactiveEngine<S, N>
where
N: Network,
S: InterestOwnerSubscriber<N>,
{
pub async fn register_handler(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), ReactiveEngineRegisterError> {
let backfill = self
.runtime
.last_canonical_block()
.filter(|retained| {
self.runtime.journal.iter().any(|entry| {
optional_block_refs_are_compatible(Some(&entry.block), Some(retained))
})
})
.map(HandlerRegistrationCatchup::CoordinatedCanonical)
.unwrap_or(HandlerRegistrationCatchup::LiveOnly);
self.register_handler_inner(handler, backfill).await
}
pub async fn register_handler_with_backfill(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
backfill: SubscriberBackfill,
) -> Result<(), ReactiveEngineRegisterError> {
self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill))
.await
}
pub async fn register_handler_live_only(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
) -> Result<(), ReactiveEngineRegisterError> {
self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly)
.await
}
async fn register_handler_inner(
&mut self,
handler: Arc<dyn ReactiveHandler<N>>,
catchup: HandlerRegistrationCatchup,
) -> Result<(), ReactiveEngineRegisterError> {
let id = handler.id();
if self.runtime.contains_handler(&id) {
return Err(RegisterError::DuplicateHandler(id).into());
}
let interests = handler.interests();
if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup {
let retained_anchor = backfill.retained_anchor().copied();
let is_exact_retained_block = retained_anchor.is_some_and(|anchor| {
backfill.start_block() == anchor.number
&& backfill.end_block() == Some(anchor.number)
&& self.runtime.journal.iter().any(|entry| {
optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor))
})
});
if !is_exact_retained_block {
return Err(ReactiveEngineRegisterError::BackfillOutsideJournal {
start_block: backfill.start_block(),
end_block: backfill.end_block(),
retained_anchor,
});
}
}
let subscribed = match catchup {
HandlerRegistrationCatchup::OwnerBackfill(backfill) => {
self.subscriber
.add_interest_owner_with_backfill(id.clone(), &interests, backfill)
.await
}
HandlerRegistrationCatchup::CoordinatedCanonical(retained) => {
self.subscriber
.add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained)
.await
}
HandlerRegistrationCatchup::LiveOnly => {
self.subscriber
.add_interest_owner(id.clone(), &interests)
.await
}
};
if let Err(error) = subscribed {
return Err(error.into());
}
self.runtime
.registry
.insert_handler_prepared(id, handler, interests);
Ok(())
}
pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> {
let owners = self
.runtime
.handler_ids()
.into_iter()
.map(|id| {
let interests = self
.runtime
.handler_interests(&id)
.map(<[ReactiveInterest<N>]>::to_vec)
.unwrap_or_default();
(id, interests)
})
.collect();
self.subscriber.replace_interest_owners(owners).await
}
pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> {
let baseline =
self.runtime
.last_canonical_block()
.ok_or(SubscriberError::InvalidConfig(
"cannot continuity-sync handlers before a canonical runtime position exists",
))?;
let backfill = SubscriberBackfill::after_canonical_block(baseline)?;
let owners = self
.runtime
.handler_ids()
.into_iter()
.map(|id| {
let interests = self
.runtime
.handler_interests(&id)
.map(<[ReactiveInterest<N>]>::to_vec)
.unwrap_or_default();
(id, interests)
})
.collect();
self.subscriber
.replace_interest_owners_with_global_backfill(owners, backfill)
.await
}
pub async fn unregister_handler(
&mut self,
id: &HandlerId,
) -> Result<Option<Arc<dyn ReactiveHandler<N>>>, SubscriberError> {
self.subscriber.remove_interest_owner(id).await?;
Ok(self.runtime.unregister_handler(id))
}
}
type FlashblockReconnectFuture<N> = Pin<
Box<
dyn Future<
Output = (
SubscriberStreamSource,
Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
),
> + Send,
>,
>;
pub struct AlloySubscriber<P, N: Network = Ethereum> {
provider: P,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblocks_provider: Option<ProviderRef>,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblock_updates:
Option<tokio::sync::mpsc::Receiver<raw_json_flashblocks::QueuedFlashblockUpdate>>,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblock_update_channel_opened: bool,
#[cfg(feature = "raw-flashblocks-json")]
rejected_external_flashblock_generation: Option<u64>,
#[cfg(feature = "raw-flashblocks-json")]
last_external_flashblock_snapshot: Option<FlashblockSnapshot>,
flashblocks_state_provider: Option<P>,
provider_ref: Option<ProviderRef>,
log_verification_provider: Option<P>,
chain_id: Option<u64>,
mode: SubscriberMode,
config: SubscriberConfig,
base_interests: Vec<ReactiveInterest<N>>,
owned_interests: Vec<OwnedSubscriberInterests<N>>,
next_owner_epoch: u64,
interests: Vec<ReactiveInterest<N>>,
log_source_ids: HashMap<Filter, usize>,
next_log_source_id: usize,
pending_backfills: VecDeque<QueuedSubscriberBackfill>,
pending_source_backfills: VecDeque<SubscriberStreamSource>,
sources_dirty: bool,
stream_revision: u64,
state: AlloySubscriberState<N>,
pending_records: VecDeque<SubscriberInputRecord<N>>,
pending_chain_controls: VecDeque<ChainControl>,
pending_reconcile_owner_records: VecDeque<BufferedSubscriberOwnerRecord<N>>,
resource_error: Option<String>,
last_seen_log_blocks: HashMap<usize, u64>,
verified_log_blocks: HashMap<(u64, B256), BlockRef>,
verified_log_block_order: VecDeque<(u64, B256)>,
recent_input_refs: VecDeque<InputRef>,
recent_input_ref_set: HashSet<InputRef>,
recent_owner_input_refs: HashMap<SubscriberOwnerEpoch, VecDeque<InputRef>>,
recent_owner_input_ref_sets: HashMap<SubscriberOwnerEpoch, HashSet<InputRef>>,
recent_compat_owner_input_refs: HashMap<HandlerId, VecDeque<InputRef>>,
recent_compat_owner_input_ref_sets: HashMap<HandlerId, HashSet<InputRef>>,
base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>,
base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec<B256>, Vec<B256>)>,
unmatched_pending_logs: VecDeque<(usize, Log)>,
latest_preconfirmation: Option<FlashblockRef>,
preconfirmed_seen_logs: HashSet<(B256, u64)>,
preconfirmed_receipted_transactions: HashSet<B256>,
preconfirmed_unavailable_receipts: HashSet<B256>,
last_certified_canonical_head: Option<BlockRef>,
pending_preconfirmation_invalidation: bool,
pending_flashblock_reconnects: FuturesUnordered<FlashblockReconnectFuture<N>>,
pending_flashblock_reconnect_sources: Vec<SubscriberStreamSource>,
flashblocks_rpc_metrics: FlashblocksRpcMetrics,
consecutive_flashblock_poll_failures: usize,
flashblock_rpc_request_times: VecDeque<Instant>,
_network: PhantomData<N>,
}
struct OwnedSubscriberInterests<N: Network = Ethereum> {
owner: HandlerId,
interests: Vec<ReactiveInterest<N>>,
epoch: Option<SubscriberOwnerEpoch>,
state: SubscriberOwnerState,
baseline: Option<BlockRef>,
progress: Option<SubscriberOwnerProgress>,
progress_stream_revision: Option<u64>,
}
#[derive(Clone)]
struct SubscriberOwnerReconcilePlan<N: Network = Ethereum> {
epoch: SubscriberOwnerEpoch,
interests: Vec<ReactiveInterest<N>>,
retained: BlockRef,
from_block: u64,
}
struct SubscriberOwnerCatchup {
logs: Vec<Log>,
certified: BlockRef,
}
#[derive(Clone, Copy)]
struct SubscriberOwnerCatchupOptions {
target_preverified: bool,
max_logs: usize,
max_log_bytes: usize,
max_requests_in_flight: usize,
}
struct SubscriberOwnerReconcileFilter {
filter: Filter,
from_block: u64,
}
struct BufferedSubscriberOwnerRecord<N: Network = Ethereum> {
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
}
const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256;
struct QueuedSubscriberBackfill {
owner: Option<HandlerId>,
epoch: Option<SubscriberOwnerEpoch>,
filters: Vec<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,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblocks_provider: None,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblock_updates: None,
#[cfg(feature = "raw-flashblocks-json")]
external_flashblock_update_channel_opened: false,
#[cfg(feature = "raw-flashblocks-json")]
rejected_external_flashblock_generation: None,
#[cfg(feature = "raw-flashblocks-json")]
last_external_flashblock_snapshot: None,
flashblocks_state_provider: None,
provider_ref: None,
log_verification_provider: None,
chain_id: None,
mode,
config,
base_interests: Vec::new(),
owned_interests: Vec::new(),
next_owner_epoch: 0,
interests: Vec::new(),
log_source_ids: HashMap::new(),
next_log_source_id: 0,
pending_backfills: VecDeque::new(),
pending_source_backfills: VecDeque::new(),
sources_dirty: true,
stream_revision: 0,
state: AlloySubscriberState::Uninitialized,
pending_records: VecDeque::new(),
pending_chain_controls: VecDeque::new(),
pending_reconcile_owner_records: VecDeque::new(),
resource_error: None,
last_seen_log_blocks: HashMap::new(),
verified_log_blocks: HashMap::new(),
verified_log_block_order: VecDeque::new(),
recent_input_refs: VecDeque::new(),
recent_input_ref_set: HashSet::new(),
recent_owner_input_refs: HashMap::new(),
recent_owner_input_ref_sets: HashMap::new(),
recent_compat_owner_input_refs: HashMap::new(),
recent_compat_owner_input_ref_sets: HashMap::new(),
base_flashblock_header: None,
base_flashblock_transactions: None,
unmatched_pending_logs: VecDeque::new(),
latest_preconfirmation: None,
preconfirmed_seen_logs: HashSet::new(),
preconfirmed_receipted_transactions: HashSet::new(),
preconfirmed_unavailable_receipts: HashSet::new(),
last_certified_canonical_head: None,
pending_preconfirmation_invalidation: false,
pending_flashblock_reconnects: FuturesUnordered::new(),
pending_flashblock_reconnect_sources: Vec::new(),
flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(),
consecutive_flashblock_poll_failures: 0,
flashblock_rpc_request_times: VecDeque::new(),
_network: PhantomData,
}
}
pub fn provider(&self) -> &P {
&self.provider
}
#[must_use]
pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self {
self.provider_ref = Some(provider);
self
}
#[cfg(feature = "raw-flashblocks-json")]
pub fn configure_external_flashblock_updates(
&mut self,
provider: ProviderRef,
) -> Result<(), SubscriberError> {
if self.external_flashblocks_provider.is_some() {
return Err(SubscriberError::InvalidConfig(
"external Flashblock updates were already configured",
));
}
if self.external_flashblock_update_channel_opened
|| self.external_flashblock_updates.is_some()
|| self.chain_id.is_some()
|| !self.base_interests.is_empty()
|| !self.owned_interests.is_empty()
|| !self.interests.is_empty()
|| !self.pending_records.is_empty()
|| !self.pending_chain_controls.is_empty()
|| !self.pending_backfills.is_empty()
|| !matches!(self.state, AlloySubscriberState::Uninitialized)
{
return Err(SubscriberError::InvalidConfig(
"external Flashblock updates must be configured before subscriber registration",
));
}
self.external_flashblocks_provider = Some(provider);
Ok(())
}
#[cfg(feature = "raw-flashblocks-json")]
pub fn open_external_flashblock_update_channel(
&mut self,
capacity: usize,
) -> Result<FlashblockUpdateSender, SubscriberError> {
if capacity == 0 {
return Err(SubscriberError::InvalidConfig(
"external Flashblock update channel capacity must be greater than zero",
));
}
let provider = self.external_flashblocks_provider.clone().ok_or(
SubscriberError::InvalidConfig(
"external Flashblock update channel requires configure_external_flashblock_updates",
),
)?;
if self.external_flashblock_update_channel_opened {
return Err(SubscriberError::InvalidConfig(
"external Flashblock update channel was already opened",
));
}
let (sender, receiver) =
raw_json_flashblocks::flashblock_update_channel(provider, capacity);
self.external_flashblock_updates = Some(receiver);
self.external_flashblock_update_channel_opened = true;
self.sources_dirty = true;
Ok(sender)
}
fn uses_external_flashblock_updates(&self) -> bool {
#[cfg(feature = "raw-flashblocks-json")]
{
self.external_flashblocks_provider.is_some()
}
#[cfg(not(feature = "raw-flashblocks-json"))]
{
false
}
}
#[must_use]
pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self {
self.flashblocks_state_provider = Some(provider);
self
}
#[must_use]
pub fn with_log_verification_provider(mut self, provider: P) -> Self {
self.log_verification_provider = Some(provider);
self
}
pub fn mode(&self) -> SubscriberMode {
self.mode
}
pub fn config(&self) -> &SubscriberConfig {
&self.config
}
pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics {
self.flashblocks_rpc_metrics
}
pub fn registered_interests(&self) -> &[ReactiveInterest<N>] {
&self.interests
}
pub fn stage_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
start: SubscriberOwnerStart,
) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
validate_subscriber_config(&self.config)?;
if matches!(&start, SubscriberOwnerStart::PostBlock(_))
&& interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
if self
.owned_interests
.iter()
.any(|entry| entry.owner == owner)
{
return Err(SubscriberOwnerError::AlreadyRegistered(owner));
}
let mut next_owned = self.clone_owned_interests();
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Staged,
baseline: None,
progress: None,
progress_stream_revision: None,
});
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let baseline = match start {
SubscriberOwnerStart::Live => None,
SubscriberOwnerStart::PostBlock(block) => {
block
.number
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
Some(block)
}
};
let sequence = self
.next_owner_epoch
.checked_add(1)
.ok_or(SubscriberOwnerError::EpochExhausted)?;
let epoch = SubscriberOwnerEpoch {
owner: owner.clone(),
sequence,
};
self.next_owner_epoch = sequence;
let entry = next_owned
.last_mut()
.expect("staged owner was appended during preflight");
entry.epoch = Some(epoch.clone());
entry.baseline = baseline;
self.owned_interests = next_owned;
self.interests = next_registered;
self.sources_dirty = true;
Ok(epoch)
}
pub fn stage_interest_owner_replacement(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
start: SubscriberOwnerStart,
) -> Result<SubscriberOwnerEpoch, SubscriberOwnerError> {
validate_subscriber_config(&self.config)?;
if matches!(&start, SubscriberOwnerStart::PostBlock(_))
&& interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
let active_count = self
.owned_interests
.iter()
.filter(|entry| {
entry.owner == owner
&& entry.state == SubscriberOwnerState::Active
&& entry.epoch.is_some()
})
.count();
if active_count != 1
|| self
.owned_interests
.iter()
.any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active)
{
return Err(SubscriberOwnerError::AlreadyRegistered(owner));
}
let mut next_owned = self.clone_owned_interests();
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Staged,
baseline: None,
progress: None,
progress_stream_revision: None,
});
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let baseline = match start {
SubscriberOwnerStart::Live => None,
SubscriberOwnerStart::PostBlock(block) => {
block
.number
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?;
Some(block)
}
};
let sequence = self
.next_owner_epoch
.checked_add(1)
.ok_or(SubscriberOwnerError::EpochExhausted)?;
let epoch = SubscriberOwnerEpoch {
owner: owner.clone(),
sequence,
};
self.next_owner_epoch = sequence;
let entry = next_owned
.last_mut()
.expect("staged replacement owner was appended during preflight");
entry.epoch = Some(epoch.clone());
entry.baseline = baseline;
self.owned_interests = next_owned;
self.interests = next_registered;
self.sources_dirty = true;
Ok(epoch)
}
pub fn interest_owner_state(
&self,
epoch: &SubscriberOwnerEpoch,
) -> Option<SubscriberOwnerState> {
self.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.map(|entry| entry.state)
}
pub fn interest_owner_progress(
&self,
epoch: &SubscriberOwnerEpoch,
) -> Option<&SubscriberOwnerProgress> {
self.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.and_then(|entry| entry.progress.as_ref())
}
pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let stream_revision = self.stream_revision;
let sources_dirty = self.sources_dirty;
let Some(entry) = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
if entry.state != SubscriberOwnerState::Staged
|| (entry.baseline.is_some()
&& (entry.progress.is_none()
|| entry.progress_stream_revision != Some(stream_revision)
|| sources_dirty))
{
return false;
}
entry.state = SubscriberOwnerState::Active;
true
}
pub fn commit_interest_owner_replacement(
&mut self,
active: &SubscriberOwnerEpoch,
replacement: &SubscriberOwnerEpoch,
) -> bool {
let Some(active_index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(active))
else {
return false;
};
let Some(replacement_index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(replacement))
else {
return false;
};
if active_index == replacement_index
|| active.owner() != replacement.owner()
|| self.owned_interests[active_index].state != SubscriberOwnerState::Active
|| self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged
|| (self.owned_interests[replacement_index].baseline.is_some()
&& (self.owned_interests[replacement_index].progress.is_none()
|| self.owned_interests[replacement_index].progress_stream_revision
!= Some(self.stream_revision)
|| self.sources_dirty))
{
return false;
}
self.owned_interests[replacement_index].state = SubscriberOwnerState::Active;
self.owned_interests.remove(active_index);
self.purge_owner_epoch(active);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
true
}
pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let Some(entry) = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
if entry.state != SubscriberOwnerState::Active {
return false;
}
entry.state = SubscriberOwnerState::Removing;
true
}
pub fn finalize_interest_owner_removal(
&mut self,
epoch: &SubscriberOwnerEpoch,
) -> Option<Vec<ReactiveInterest<N>>> {
let index = self.owned_interests.iter().position(|entry| {
entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing
})?;
let removed = self.owned_interests.remove(index).interests;
self.purge_owner_epoch(epoch);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
Some(removed)
}
pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool {
let Some(index) = self
.owned_interests
.iter()
.position(|entry| entry.epoch.as_ref() == Some(epoch))
else {
return false;
};
match self.owned_interests[index].state {
SubscriberOwnerState::Staged => {
self.owned_interests.remove(index);
self.purge_owner_epoch(epoch);
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
true
}
SubscriberOwnerState::Removing => {
self.owned_interests[index].state = SubscriberOwnerState::Active;
true
}
SubscriberOwnerState::Active => false,
}
}
fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) {
self.pending_backfills
.retain(|backfill| backfill.epoch.as_ref() != Some(epoch));
self.pending_records
.retain_mut(|pending| match &mut pending.scope {
SubscriberInputScope::Canonical { owners }
| SubscriberInputScope::CanonicalResidual { owners, .. } => {
owners.retain(|owner| owner != epoch);
true
}
SubscriberInputScope::OwnerOnly { owners } => {
owners.retain(|owner| owner != epoch);
!owners.is_empty()
}
SubscriberInputScope::OwnerOnlyHandlers { .. }
| SubscriberInputScope::Preconfirmed => true,
});
self.pending_reconcile_owner_records.retain_mut(|pending| {
pending.owners.retain(|owner| owner != epoch);
!pending.owners.is_empty()
});
self.recent_owner_input_refs.remove(epoch);
self.recent_owner_input_ref_sets.remove(epoch);
}
pub fn upsert_interest_owners(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> Result<(), SubscriberError> {
self.upsert_interest_owners_inner(owners, None)
}
pub fn upsert_interest_owners_with_backfill(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
backfill: SubscriberBackfill,
) -> Result<(), SubscriberError> {
self.upsert_interest_owners_inner(owners, Some(backfill))
}
fn upsert_interest_owners_inner(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
explicit_backfill: Option<SubscriberBackfill>,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
let mut seen = HashSet::with_capacity(owners.len());
let mut next_owned = self.clone_owned_interests();
for (owner, interests) in &owners {
if !seen.insert(owner.clone()) {
return Err(SubscriberError::InvalidConfig(
"bulk owner upsert contains a duplicate owner",
));
}
if self
.owned_interests
.iter()
.any(|entry| &entry.owner == owner && entry.epoch.is_some())
{
return Err(SubscriberError::InvalidConfig(
"cannot mix compatibility and epoch-scoped owner lifecycle APIs",
));
}
if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) {
entry.interests = interests.clone();
entry.state = SubscriberOwnerState::Active;
entry.baseline = None;
entry.progress = None;
entry.progress_stream_revision = None;
} else {
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.clone(),
epoch: None,
state: SubscriberOwnerState::Active,
baseline: None,
progress: None,
progress_stream_revision: None,
});
}
}
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let mut replacement_backfills = Vec::new();
for (owner, interests) in &owners {
let previous_filters: Vec<Filter> = self
.owner_interests(owner)
.map(log_filters)
.unwrap_or_default();
let continuity_anchor = previous_filters
.iter()
.filter_map(|filter| self.log_anchor(filter))
.min();
let filters = log_filters(interests);
if let Some(backfill) = explicit_backfill
&& !filters.is_empty()
{
replacement_backfills.push(QueuedSubscriberBackfill {
owner: Some(owner.clone()),
epoch: None,
filters: filters.clone(),
backfill,
});
}
let explicit_covers = explicit_backfill.is_some_and(|explicit| {
explicit.end_block().is_none()
&& continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
});
let continuity_filters: Vec<_> = filters
.into_iter()
.filter(|filter| !previous_filters.contains(filter))
.collect();
if let Some(anchor) = continuity_anchor
&& !continuity_filters.is_empty()
&& !explicit_covers
{
replacement_backfills.push(QueuedSubscriberBackfill {
owner: Some(owner.clone()),
epoch: None,
filters: continuity_filters,
backfill: SubscriberBackfill::from_block(anchor),
});
}
}
let retained_backfills = self
.pending_backfills
.iter()
.filter(|queued| {
queued
.owner
.as_ref()
.is_none_or(|owner| !seen.contains(owner))
})
.map(|queued| queued.filters.len())
.sum::<usize>();
let replacement_units = replacement_backfills
.iter()
.map(|queued| queued.filters.len())
.sum::<usize>();
if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
{
return Err(SubscriberError::ResourceExhausted(format!(
"bulk owner update would queue more than {} lazy backfills",
self.config.max_pending_backfills
)));
}
self.owned_interests = next_owned;
self.interests = next_registered;
for owner in &seen {
self.recent_compat_owner_input_refs.remove(owner);
self.recent_compat_owner_input_ref_sets.remove(owner);
}
self.retire_unreferenced_filters();
self.sources_dirty = true;
self.pending_backfills.retain(|queued| {
queued
.owner
.as_ref()
.is_none_or(|owner| !seen.contains(owner))
});
self.pending_backfills.extend(replacement_backfills);
Ok(())
}
pub fn replace_interest_owners(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> Result<(), SubscriberError> {
self.replace_interest_owners_inner(owners, None)
}
pub fn replace_interest_owners_with_global_backfill(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
backfill: SubscriberBackfill,
) -> Result<(), SubscriberError> {
self.replace_interest_owners_inner(owners, Some(backfill))
}
fn replace_interest_owners_inner(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
backfill: Option<SubscriberBackfill>,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
if self
.owned_interests
.iter()
.any(|entry| entry.epoch.is_some())
{
return Err(SubscriberError::InvalidConfig(
"cannot replace compatibility owners while an epoch-scoped lifecycle exists",
));
}
let mut seen = HashSet::with_capacity(owners.len());
let mut next_owned = Vec::with_capacity(owners.len());
for (owner, interests) in owners {
if !seen.insert(owner.clone()) {
return Err(SubscriberError::InvalidConfig(
"owner replacement contains a duplicate owner",
));
}
next_owned.push(OwnedSubscriberInterests {
owner,
interests,
epoch: None,
state: SubscriberOwnerState::Active,
baseline: None,
progress: None,
progress_stream_revision: None,
});
}
let next_registered = aggregate_interests(&[], &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let mut filters = log_filters(&next_registered);
let mut unique_filters = Vec::with_capacity(filters.len());
for filter in filters.drain(..) {
if !unique_filters.contains(&filter) {
unique_filters.push(filter);
}
}
let replacement_backfills: VecDeque<_> = match backfill {
Some(backfill) if !unique_filters.is_empty() => {
VecDeque::from([QueuedSubscriberBackfill {
owner: None,
epoch: None,
filters: unique_filters,
backfill,
}])
}
Some(_) | None => VecDeque::new(),
};
let replacement_units = replacement_backfills
.iter()
.map(|queued| queued.filters.len())
.sum::<usize>();
if replacement_units > self.config.max_pending_backfills {
return Err(SubscriberError::ResourceExhausted(format!(
"owner replacement would queue more than {} lazy backfills",
self.config.max_pending_backfills
)));
}
let revoke_preconfirmation = self.latest_preconfirmation.is_some()
|| self.pending_preconfirmation_invalidation
|| self.pending_records.iter().any(|record| {
record.scope == SubscriberInputScope::Preconfirmed
|| matches!(
&record.record.context.chain_status,
ChainStatus::Preconfirmed { .. }
)
});
self.base_interests.clear();
self.owned_interests = next_owned;
self.interests = next_registered;
self.reset_delivery_state();
self.pending_preconfirmation_invalidation = revoke_preconfirmation;
self.pending_backfills = replacement_backfills;
self.reset_stream_topology();
Ok(())
}
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 add_interest_owner_with_canonical_catchup(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
retained: BlockRef,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
if self
.owned_interests
.iter()
.any(|entry| entry.owner == owner && entry.epoch.is_some())
{
return Err(SubscriberError::InvalidConfig(
"cannot mix compatibility and epoch-scoped owner lifecycle APIs",
));
}
let mut next_owned = self.clone_owned_interests();
if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) {
entry.interests = interests.to_vec();
entry.state = SubscriberOwnerState::Active;
entry.baseline = None;
entry.progress = None;
entry.progress_stream_revision = None;
entry.epoch = None;
} else {
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Active,
baseline: None,
progress: None,
progress_stream_revision: None,
});
}
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
if next_registered
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberError::Unsupported(
"Alloy coordinated registration supports log-only interest topologies",
));
}
let mut owner_filters = Vec::new();
for filter in log_filters(interests) {
if !owner_filters.contains(&filter) {
owner_filters.push(filter);
}
}
let mut global_filters = Vec::new();
for filter in log_filters(&next_registered) {
if !global_filters.contains(&filter) {
global_filters.push(filter);
}
}
let owner_backfill =
SubscriberBackfill::from_canonical_block_through(retained, retained.number)?;
let global_backfill = SubscriberBackfill::after_canonical_block(retained)?;
let replacement_units = owner_filters.len().saturating_add(global_filters.len());
let retained_units = self
.pending_backfills
.iter()
.filter(|queued| queued.owner.as_ref() != Some(&owner))
.map(|queued| queued.filters.len())
.sum::<usize>();
if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills {
return Err(SubscriberError::ResourceExhausted(format!(
"coordinated owner registration would queue more than {} lazy backfills",
self.config.max_pending_backfills
)));
}
let mut replacement_backfills = VecDeque::new();
if !owner_filters.is_empty() {
replacement_backfills.push_back(QueuedSubscriberBackfill {
owner: Some(owner.clone()),
epoch: None,
filters: owner_filters,
backfill: owner_backfill,
});
}
replacement_backfills.push_back(QueuedSubscriberBackfill {
owner: None,
epoch: None,
filters: global_filters,
backfill: global_backfill,
});
self.owned_interests = next_owned;
self.interests = next_registered;
self.recent_compat_owner_input_refs.remove(&owner);
self.recent_compat_owner_input_ref_sets.remove(&owner);
self.pending_backfills
.retain(|queued| queued.owner.as_ref() != Some(&owner));
self.pending_backfills.extend(replacement_backfills);
self.retire_unreferenced_filters();
self.sources_dirty = true;
Ok(())
}
pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option<Vec<ReactiveInterest<N>>> {
let index = self
.owned_interests
.iter()
.position(|entry| &entry.owner == owner && entry.epoch.is_none())?;
let removed = self.owned_interests.remove(index);
if let Some(epoch) = &removed.epoch {
self.purge_owner_epoch(epoch);
} else {
self.pending_backfills
.retain(|backfill| backfill.owner.as_ref() != Some(owner));
self.recent_compat_owner_input_refs.remove(owner);
self.recent_compat_owner_input_ref_sets.remove(owner);
}
self.rebuild_registered_interests();
self.retire_unreferenced_filters();
self.sources_dirty = true;
Some(removed.interests)
}
pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest<N>]> {
self.owned_interests
.iter()
.find(|entry| &entry.owner == owner)
.map(|entry| entry.interests.as_slice())
}
fn set_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: Option<SubscriberBackfill>,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
if self
.owned_interests
.iter()
.any(|entry| entry.owner == owner && entry.epoch.is_some())
{
return Err(SubscriberError::InvalidConfig(
"cannot mix compatibility and epoch-scoped owner lifecycle APIs",
));
}
let mut next_owned = self.clone_owned_interests();
let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) {
Some(entry) => {
entry.interests = interests.to_vec();
entry.state = SubscriberOwnerState::Active;
entry.baseline = None;
entry.progress = None;
entry.progress_stream_revision = None;
entry.epoch.take()
}
None => {
next_owned.push(OwnedSubscriberInterests {
owner: owner.clone(),
interests: interests.to_vec(),
epoch: None,
state: SubscriberOwnerState::Active,
baseline: None,
progress: None,
progress_stream_revision: None,
});
None
}
};
let next_registered = aggregate_interests(&self.base_interests, &next_owned);
validate_supported_interests(self.mode, &self.config, &next_registered)?;
let previous_filters: Vec<Filter> = self
.owner_interests(&owner)
.map(log_filters)
.unwrap_or_default();
let continuity_anchor: Option<u64> = previous_filters
.iter()
.filter_map(|filter| self.log_anchor(filter))
.min();
let mut replacement_backfills = Vec::new();
let filters = log_filters(interests);
if let Some(backfill) = backfill
&& !filters.is_empty()
{
replacement_backfills.push(QueuedSubscriberBackfill {
owner: Some(owner.clone()),
epoch: None,
filters: filters.clone(),
backfill,
});
}
let explicit_covers = backfill.is_some_and(|explicit| {
explicit.end_block().is_none()
&& continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor)
});
let continuity_filters: Vec<_> = filters
.into_iter()
.filter(|filter| !previous_filters.contains(filter))
.collect();
if let Some(anchor) = continuity_anchor
&& !continuity_filters.is_empty()
&& !explicit_covers
{
replacement_backfills.push(QueuedSubscriberBackfill {
owner: Some(owner.clone()),
epoch: None,
filters: continuity_filters,
backfill: SubscriberBackfill::from_block(anchor),
});
}
let retained_backfills = self
.pending_backfills
.iter()
.filter(|queued| queued.owner.as_ref() != Some(&owner))
.map(|queued| queued.filters.len())
.sum::<usize>();
let replacement_units = replacement_backfills
.iter()
.map(|queued| queued.filters.len())
.sum::<usize>();
if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills
{
return Err(SubscriberError::ResourceExhausted(format!(
"owner update would queue more than {} lazy backfills",
self.config.max_pending_backfills
)));
}
self.owned_interests = next_owned;
self.interests = next_registered;
if let Some(epoch) = replaced_epoch {
self.purge_owner_epoch(&epoch);
} else {
self.recent_compat_owner_input_refs.remove(&owner);
self.recent_compat_owner_input_ref_sets.remove(&owner);
}
self.retire_unreferenced_filters();
self.sources_dirty = true;
self.pending_backfills
.retain(|queued| queued.owner.as_ref() != Some(&owner));
self.pending_backfills.extend(replacement_backfills);
Ok(())
}
fn clone_owned_interests(&self) -> Vec<OwnedSubscriberInterests<N>> {
self.owned_interests
.iter()
.map(|entry| OwnedSubscriberInterests {
owner: entry.owner.clone(),
interests: entry.interests.clone(),
epoch: entry.epoch.clone(),
state: entry.state,
baseline: entry.baseline,
progress: entry.progress.clone(),
progress_stream_revision: entry.progress_stream_revision,
})
.collect()
}
fn rebuild_registered_interests(&mut self) {
self.interests = aggregate_interests(&self.base_interests, &self.owned_interests);
}
fn log_anchor(&self, filter: &Filter) -> Option<u64> {
if let Some(anchor) = self
.log_source_ids
.get(filter)
.and_then(|id| self.last_seen_log_blocks.get(id))
{
return Some(*anchor);
}
self.log_source_ids
.values()
.filter_map(|id| self.last_seen_log_blocks.get(id).copied())
.min()
}
#[allow(clippy::mutable_key_type)]
fn logical_log_filters(&self) -> Vec<Filter> {
let mut filters = log_filters(&self.base_interests);
for entry in &self.owned_interests {
filters.extend(log_filters(&entry.interests));
}
let mut seen = HashSet::new();
filters.retain(|filter| seen.insert(filter.clone()));
filters
}
fn log_stream_filters(&self) -> Vec<Filter> {
let mut merged = Vec::new();
for filter in self.logical_log_filters() {
merge_log_subscription_filter(&mut merged, &filter);
}
let max_addresses = self.config.max_log_addresses_per_subscription.max(1);
let mut planned = Vec::new();
for filter in merged {
let mut addresses: Vec<_> = filter.address.iter().copied().collect();
if addresses.len() <= max_addresses {
planned.push(filter);
continue;
}
addresses.sort_unstable();
for chunk in addresses.chunks(max_addresses) {
let mut split = filter.clone();
split.address = FilterSet::default();
for address in chunk {
split.address.insert(*address);
}
planned.push(split);
}
}
planned
}
#[allow(clippy::mutable_key_type)]
fn retire_unreferenced_filters(&mut self) {
let mut live: HashSet<Filter> = self.log_stream_filters().into_iter().collect();
if let AlloySubscriberState::Active(streams) = &self.state {
for entry in &streams.entries {
match &entry.source {
SubscriberStreamSource::PubSubLog { filter, .. }
| SubscriberStreamSource::BasePendingLog { filter, .. }
| SubscriberStreamSource::PollingLog { filter } => {
live.insert(filter.clone());
}
SubscriberStreamSource::BaseFlashblocks
| SubscriberStreamSource::OpPendingFlashblocks
| SubscriberStreamSource::CanonicalHeadPolling
| SubscriberStreamSource::PubSubPendingHashes
| SubscriberStreamSource::PubSubBlockHeaders
| SubscriberStreamSource::PollingPendingHashes => {}
#[cfg(feature = "raw-flashblocks-json")]
SubscriberStreamSource::ExternalFlashblockUpdates => {}
}
}
}
self.log_source_ids
.retain(|filter, _| live.contains(filter));
let live_ids: HashSet<usize> = self.log_source_ids.values().copied().collect();
self.last_seen_log_blocks
.retain(|id, _| live_ids.contains(id));
}
fn drain_next_scoped_batch(&mut self) -> Option<SubscriberInputBatch<N>> {
if self.pending_records.is_empty()
&& self.pending_chain_controls.is_empty()
&& !self.pending_preconfirmation_invalidation
{
return None;
}
let first_preconfirmation = self.pending_records.front().and_then(|record| {
if record.scope != SubscriberInputScope::Preconfirmed {
return None;
}
match &record.record.context.chain_status {
ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()),
_ => None,
}
});
let len = self
.pending_records
.iter()
.take(self.config.max_batch_size)
.take_while(|record| match &first_preconfirmation {
Some(expected) => {
record.scope == SubscriberInputScope::Preconfirmed
&& matches!(
&record.record.context.chain_status,
ChainStatus::Preconfirmed { flashblock } if flashblock == expected
)
}
None => record.scope != SubscriberInputScope::Preconfirmed,
})
.count();
let records = self.pending_records.drain(..len).collect();
let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() {
self.pending_chain_controls.drain(..).collect()
} else {
Vec::new()
};
Some(SubscriberInputBatch {
records,
chain_id: self.chain_id,
chain_controls,
preconfirmation_invalidated: std::mem::take(
&mut self.pending_preconfirmation_invalidation,
),
})
}
fn reset_delivery_state(&mut self) {
self.pending_records.clear();
self.pending_chain_controls.clear();
self.pending_reconcile_owner_records.clear();
self.resource_error = None;
self.last_seen_log_blocks.clear();
self.verified_log_blocks.clear();
self.verified_log_block_order.clear();
self.recent_input_refs.clear();
self.recent_input_ref_set.clear();
self.recent_owner_input_refs.clear();
self.recent_owner_input_ref_sets.clear();
self.recent_compat_owner_input_refs.clear();
self.recent_compat_owner_input_ref_sets.clear();
self.pending_backfills.clear();
self.pending_source_backfills.clear();
self.pending_preconfirmation_invalidation = false;
self.pending_flashblock_reconnects.clear();
self.pending_flashblock_reconnect_sources.clear();
self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default();
self.log_source_ids.clear();
self.next_log_source_id = 0;
self.sources_dirty = true;
self.last_certified_canonical_head = None;
self.reset_flashblock_tracking();
}
fn reset_stream_topology(&mut self) {
#[cfg(feature = "raw-flashblocks-json")]
let external = match &mut self.state {
AlloySubscriberState::Active(streams) => streams
.entries
.iter()
.position(|entry| entry.source.is_external_flashblocks())
.map(|index| streams.entries.remove(index)),
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
};
#[cfg(feature = "raw-flashblocks-json")]
if let Some(external) = external {
let mut streams = SubscriberStreams::new();
streams.entries.push(external);
self.state = AlloySubscriberState::Active(streams);
return;
}
self.state = AlloySubscriberState::Uninitialized;
}
fn reset_flashblock_tracking(&mut self) {
self.base_flashblock_header = None;
self.base_flashblock_transactions = None;
self.unmatched_pending_logs.clear();
self.latest_preconfirmation = None;
self.preconfirmed_seen_logs.clear();
self.preconfirmed_receipted_transactions.clear();
self.preconfirmed_unavailable_receipts.clear();
#[cfg(feature = "raw-flashblocks-json")]
{
self.last_external_flashblock_snapshot = None;
}
self.consecutive_flashblock_poll_failures = 0;
}
fn invalidate_preconfirmation_snapshot(&mut self) {
self.pending_records
.retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
self.pending_preconfirmation_invalidation = true;
self.latest_preconfirmation = None;
self.preconfirmed_seen_logs.clear();
self.preconfirmed_receipted_transactions.clear();
self.preconfirmed_unavailable_receipts.clear();
}
fn bump_stream_revision(&mut self) {
self.stream_revision = self.stream_revision.saturating_add(1);
}
}
impl<P, N> InterestOwnerSubscriber<N> for AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
fn upsert_interest_owners(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> SubscriberOperation<'_, ()> {
Box::pin(async move {
if !owners.is_empty() {
self.ensure_chain_id().await?;
}
AlloySubscriber::upsert_interest_owners(self, owners)
})
}
fn replace_interest_owners(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
) -> SubscriberOperation<'_, ()> {
Box::pin(async move {
if owners.iter().any(|(_, interests)| !interests.is_empty()) {
self.ensure_chain_id().await?;
}
AlloySubscriber::replace_interest_owners(self, owners)
})
}
fn replace_interest_owners_with_global_backfill(
&mut self,
owners: Vec<(HandlerId, Vec<ReactiveInterest<N>>)>,
backfill: SubscriberBackfill,
) -> SubscriberOperation<'_, ()> {
Box::pin(async move {
if owners.iter().any(|(_, interests)| !interests.is_empty()) {
self.ensure_chain_id().await?;
}
AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill)
})
}
fn add_interest_owner(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
) -> SubscriberOperation<'_, ()> {
let interests = interests.to_vec();
Box::pin(async move {
if !interests.is_empty() {
self.ensure_chain_id().await?;
}
AlloySubscriber::add_interest_owner(self, owner, &interests)
})
}
fn add_interest_owner_with_backfill(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
backfill: SubscriberBackfill,
) -> SubscriberOperation<'_, ()> {
let interests = interests.to_vec();
Box::pin(async move {
if !interests.is_empty() {
self.ensure_chain_id().await?;
}
AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill)
})
}
fn add_interest_owner_with_canonical_catchup(
&mut self,
owner: HandlerId,
interests: &[ReactiveInterest<N>],
retained: BlockRef,
) -> SubscriberOperation<'_, ()> {
let interests = interests.to_vec();
Box::pin(async move {
self.ensure_chain_id().await?;
AlloySubscriber::add_interest_owner_with_canonical_catchup(
self, owner, &interests, retained,
)
})
}
fn remove_interest_owner(
&mut self,
owner: &HandlerId,
) -> SubscriberOperation<'_, Option<Vec<ReactiveInterest<N>>>> {
let owner = owner.clone();
Box::pin(async move { Ok(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(test)]
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,
},
BasePendingLog {
id: usize,
filter: Filter,
},
BaseFlashblocks,
OpPendingFlashblocks,
CanonicalHeadPolling,
PubSubPendingHashes,
PubSubBlockHeaders,
PollingLog {
filter: Filter,
},
PollingPendingHashes,
#[cfg(feature = "raw-flashblocks-json")]
ExternalFlashblockUpdates,
}
impl SubscriberStreamSource {
fn label(&self) -> &'static str {
match self {
Self::PubSubLog { .. } => "pubsub log",
Self::BasePendingLog { .. } => "OP Stack pendingLogs",
Self::BaseFlashblocks => "OP Stack newFlashblocks",
Self::OpPendingFlashblocks => "Optimism pending Flashblocks",
Self::CanonicalHeadPolling => "certified canonical head",
Self::PubSubPendingHashes => "pubsub pending transaction hash",
Self::PubSubBlockHeaders => "pubsub block header",
Self::PollingLog { .. } => "polling log",
Self::PollingPendingHashes => "polling pending transaction hash",
#[cfg(feature = "raw-flashblocks-json")]
Self::ExternalFlashblockUpdates => "external standardized Flashblock update",
}
}
fn is_pubsub(&self) -> bool {
matches!(
self,
Self::PubSubLog { .. }
| Self::BasePendingLog { .. }
| Self::BaseFlashblocks
| Self::OpPendingFlashblocks
| Self::PubSubPendingHashes
| Self::PubSubBlockHeaders
)
}
fn is_flashblocks(&self) -> bool {
matches!(
self,
Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks
)
}
fn same_key(&self, other: &Self) -> bool {
match (self, other) {
(Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. })
| (
Self::BasePendingLog { filter: left, .. },
Self::BasePendingLog { filter: right, .. },
)
| (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => {
left == right
}
(Self::BaseFlashblocks, Self::BaseFlashblocks)
| (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks)
| (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling)
| (Self::PubSubPendingHashes, Self::PubSubPendingHashes)
| (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders)
| (Self::PollingPendingHashes, Self::PollingPendingHashes) => true,
#[cfg(feature = "raw-flashblocks-json")]
(Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true,
_ => false,
}
}
fn is_external_flashblocks(&self) -> bool {
#[cfg(feature = "raw-flashblocks-json")]
{
matches!(self, Self::ExternalFlashblockUpdates)
}
#[cfg(not(feature = "raw-flashblocks-json"))]
{
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>),
BasePendingLog {
source_id: usize,
log: Log,
},
BaseFlashblock(BaseFlashblockWirePayload),
OpFlashblockTick,
CanonicalHeadTick,
PreconfirmedLogs {
flashblock: FlashblockRef,
logs: Vec<Log>,
},
FlashblockInvalidated,
FlashblockObserved,
#[cfg(feature = "raw-flashblocks-json")]
ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate),
StreamTerminated(SubscriberStreamSource),
}
enum SubscriberReady<N: Network> {
Event(Option<SubscriberEvent<N>>),
FlashblockReconnect(
SubscriberStreamSource,
Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>,
),
}
#[derive(Debug)]
enum PendingFlashblockPollError {
Request(SubscriberError),
Integrity(SubscriberError),
}
impl PendingFlashblockPollError {
fn into_subscriber(self) -> SubscriberError {
match self {
Self::Request(error) | Self::Integrity(error) => error,
}
}
}
fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError {
PendingFlashblockPollError::Request(provider_error(error))
}
fn normalize_op_pending_block<N: Network>(
mut value: serde_json::Value,
) -> Result<N::BlockResponse, SubscriberError> {
let object = value.as_object_mut().ok_or_else(|| {
SubscriberError::Provider("OP pending block response is not an object".into())
})?;
let transactions = object
.get_mut("transactions")
.and_then(serde_json::Value::as_array_mut)
.ok_or_else(|| {
SubscriberError::Provider(
"OP pending block response is missing its transaction array".into(),
)
})?;
for transaction in transactions {
if transaction.is_string() {
continue;
}
let hash = transaction
.as_object()
.and_then(|object| object.get("hash"))
.filter(|hash| hash.is_string())
.cloned()
.ok_or_else(|| {
SubscriberError::Provider("OP pending block transaction is missing its hash".into())
})?;
*transaction = hash;
}
if object.get("hash").is_none_or(serde_json::Value::is_null) {
object.insert(
"hash".into(),
serde_json::Value::String(B256::ZERO.to_string()),
);
}
if object.get("nonce").is_none_or(serde_json::Value::is_null) {
object.insert(
"nonce".into(),
serde_json::Value::String("0x0000000000000000".into()),
);
}
if object.get("miner").is_none_or(serde_json::Value::is_null)
|| object
.get("beneficiary")
.is_none_or(serde_json::Value::is_null)
{
object.insert(
"miner".into(),
serde_json::Value::String(Address::ZERO.to_string()),
);
}
serde_json::from_value(value).map_err(|error| {
SubscriberError::Provider(format!(
"failed to decode normalized OP pending block: {error}"
))
})
}
fn normalize_pending_transaction_receipt(
expected_transaction_hash: B256,
value: serde_json::Value,
) -> Result<Option<Vec<Log>>, SubscriberError> {
if value.is_null() {
return Ok(None);
}
let receipt = value.as_object().ok_or_else(|| {
SubscriberError::Provider("pending transaction receipt response is not an object".into())
})?;
let transaction_hash: B256 =
serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| {
SubscriberError::Provider(
"pending transaction receipt is missing its transaction hash".into(),
)
})?)
.map_err(|error| {
SubscriberError::Provider(format!(
"failed to decode pending transaction receipt hash: {error}"
))
})?;
if transaction_hash != expected_transaction_hash {
return Err(SubscriberError::Provider(
"pending transaction receipt hash disagrees with its request".into(),
));
}
let receipt_logs = receipt
.get("logs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
SubscriberError::Provider("pending transaction receipt is missing its log array".into())
})?;
let mut logs = Vec::new();
for log in receipt_logs {
let log: Log = serde_json::from_value(log.clone()).map_err(|error| {
SubscriberError::Provider(format!(
"failed to decode pending transaction receipt log: {error}"
))
})?;
if log.transaction_hash != Some(expected_transaction_hash) {
return Err(SubscriberError::Provider(
"pending transaction receipt log hash disagrees with its receipt".into(),
));
}
logs.push(log);
}
Ok(Some(logs))
}
impl<P, N> EventSubscriber<N> for AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
fn chain_id(&self) -> Option<u64> {
self.chain_id
}
fn capabilities(&self) -> SubscriberCapabilities {
let Ok(transport) = resolve_subscriber_transport(self.mode) else {
return SubscriberCapabilities::default();
};
let mut capabilities = vec![
SubscriberCapability::Logs,
SubscriberCapability::PendingTransactionHashes,
SubscriberCapability::HistoricalBackfill,
SubscriberCapability::Live,
SubscriberCapability::OwnerScopedDelivery,
SubscriberCapability::DynamicInterests,
];
if transport == SubscriberTransport::PubSub {
capabilities.push(SubscriberCapability::BlockHeaders);
}
if self.config.preconfirmations != PreconfirmationMode::Disabled
&& (self.uses_external_flashblock_updates()
|| (self.provider_ref.is_some()
&& self.chain_id.and_then(flashblocks_adapter).is_some()))
{
capabilities.push(SubscriberCapability::Preconfirmations);
}
SubscriberCapabilities::new(capabilities)
}
fn register_interests(
&mut self,
interests: &[ReactiveInterest<N>],
) -> SubscriberOperation<'_, ()> {
let interests = interests.to_vec();
Box::pin(async move {
validate_subscriber_config(&self.config)?;
validate_supported_interests(self.mode, &self.config, &interests)?;
if !interests.is_empty() {
self.ensure_chain_id().await?;
}
self.validate_flashblocks_setup()?;
self.base_interests = interests;
self.owned_interests.clear();
self.rebuild_registered_interests();
self.reset_delivery_state();
self.reset_stream_topology();
Ok(())
})
}
fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> {
Box::pin(async {
Ok(self
.next_scoped_batch()
.await?
.map(SubscriberInputBatch::into_reactive_batch))
})
}
}
impl<P, N> AlloySubscriber<P, N>
where
P: Provider<N> + Send + Sync,
N: Network + 'static,
N::HeaderResponse: Send + 'static,
{
pub async fn establish_flashblocks_preflight(
&mut self,
expected_chain_id: u64,
) -> Result<FlashblocksPreflight, SubscriberError> {
validate_subscriber_config(&self.config)?;
if self.config.preconfirmations == PreconfirmationMode::Disabled {
return Err(SubscriberError::InvalidConfig(
"Flashblocks preflight requires preconfirmations",
));
}
if !self
.interests
.iter()
.any(|interest| matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberError::InvalidConfig(
"Flashblocks preflight requires at least one active log interest",
));
}
let chain_id = self.ensure_chain_id().await?;
if chain_id != expected_chain_id {
return Err(SubscriberError::ChainMismatch {
expected: expected_chain_id,
actual: chain_id,
});
}
self.validate_flashblocks_setup()?;
#[cfg(feature = "raw-flashblocks-json")]
if let Some(provider) = self.external_flashblocks_provider.clone() {
self.ensure_streams().await?;
return Ok(FlashblocksPreflight {
chain_id,
provider,
delivery: FlashblocksDelivery::ExternalUpdates,
pending_log_subscriptions: 0,
pending_log_filters: self.log_stream_filters().len(),
advertised_capabilities: None,
});
}
let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported(
"Flashblocks are currently implemented for Base and OP chains",
))?;
let provider = self
.provider_ref
.clone()
.ok_or(SubscriberError::InvalidConfig(
"Flashblocks preflight requires a stable provider ref",
))?;
self.flashblocks_rpc_metrics.capability_requests = self
.flashblocks_rpc_metrics
.capability_requests
.saturating_add(1);
let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling {
self.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
} else {
&self.provider
};
let advertised_capabilities = capability_provider
.client()
.request::<_, serde_json::Value>("op_supportedCapabilities", ())
.await
.ok();
self.ensure_streams().await?;
let pending_log_filters = self.log_stream_filters();
if adapter == FlashblocksAdapter::PendingStatePolling
&& self.pending_receipt_requests_per_tick_capacity() == 0
{
return Err(SubscriberError::InvalidConfig(
"Flashblocks RPC budget leaves no capacity for OP transaction receipts",
));
}
let (delivery, pending_log_subscriptions) = match adapter {
FlashblocksAdapter::NativeSubscriptions => {
if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub {
return Err(SubscriberError::Unsupported(
"Base Flashblocks preflight requires pubsub",
));
}
let pending_sources = self
.pubsub_stream_sources()
.into_iter()
.filter(|source| {
matches!(source, SubscriberStreamSource::BasePendingLog { .. })
})
.collect::<Vec<_>>();
let AlloySubscriberState::Active(streams) = &self.state else {
return Err(SubscriberError::Provider(
"Flashblocks preflight subscriptions did not become active".to_owned(),
));
};
if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks)
|| pending_sources
.iter()
.any(|source| !streams.contains_source(source))
{
return Err(SubscriberError::Provider(
"Base Flashblocks preflight did not retain both subscription lanes"
.to_owned(),
));
}
(
FlashblocksDelivery::NativeSubscriptions,
pending_sources.len(),
)
}
FlashblocksAdapter::PendingStatePolling => {
if let Some(state_provider) = self.flashblocks_state_provider.as_ref() {
self.flashblocks_rpc_metrics.provider_pair_chain_requests = self
.flashblocks_rpc_metrics
.provider_pair_chain_requests
.saturating_add(1);
let actual = state_provider
.get_chain_id()
.await
.map_err(provider_error)?;
if actual != expected_chain_id {
return Err(SubscriberError::ChainMismatch {
expected: expected_chain_id,
actual,
});
}
}
let AlloySubscriberState::Active(streams) = &self.state else {
return Err(SubscriberError::Provider(
"Flashblocks preflight streams did not become active".to_owned(),
));
};
if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) {
return Err(SubscriberError::Provider(
"Optimism Flashblocks preflight did not retain its pending-state sampler"
.to_owned(),
));
}
self.probe_pending_state(&pending_log_filters).await?;
(FlashblocksDelivery::PendingStatePolling, 0)
}
};
Ok(FlashblocksPreflight {
chain_id,
provider,
delivery,
pending_log_subscriptions,
pending_log_filters: pending_log_filters.len(),
advertised_capabilities,
})
}
#[cfg(feature = "raw-flashblocks-json")]
pub fn ingest_flashblock_update(
&mut self,
update: FlashblockUpdate,
) -> Result<(), SubscriberError> {
validate_subscriber_config(&self.config)?;
self.validate_flashblocks_setup()?;
let configured =
self.external_flashblocks_provider
.as_ref()
.ok_or(SubscriberError::InvalidConfig(
"standardized Flashblock updates require configure_external_flashblock_updates",
))?;
match update {
FlashblockUpdate::Snapshot(batch) => {
if batch.flashblock.provider.endpoint != configured.endpoint {
return Err(SubscriberError::Provider(
"external Flashblock update came from an unexpected provider endpoint"
.into(),
));
}
if batch.flashblock.provider.generation < configured.generation
|| self.latest_preconfirmation.as_ref().is_some_and(|latest| {
latest.provider.endpoint == batch.flashblock.provider.endpoint
&& latest.provider.generation > batch.flashblock.provider.generation
})
{
return Ok(());
}
if self
.rejected_external_flashblock_generation
.is_some_and(|rejected| batch.flashblock.provider.generation <= rejected)
{
return Err(SubscriberError::Provider(
"external Flashblock provider generation was previously rejected".into(),
));
}
validate_standard_flashblock_snapshot(&batch)?;
if self.validate_external_flashblock_sequence(&batch)? {
return Ok(());
}
let required = self.pending_record_count().saturating_add(batch.logs.len());
if required > self.config.max_pending_records {
self.invalidate_preconfirmation_snapshot();
self.last_external_flashblock_snapshot = None;
return Err(SubscriberError::ResourceExhausted(format!(
"external preconfirmation records require {required} pending records, above the configured limit of {}",
self.config.max_pending_records
)));
}
let accepted_snapshot = (*batch).clone();
let FlashblockSnapshot { flashblock, logs } = *batch;
let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
self.last_external_flashblock_snapshot = Some(accepted_snapshot);
if let Some(provider) = self.external_flashblocks_provider.as_mut() {
provider.generation = provider.generation.max(flashblock.provider.generation);
}
if !logs.is_empty() {
self.enqueue_event(SubscriberEvent::PreconfirmedLogs { flashblock, logs });
}
}
FlashblockUpdate::Invalidated(invalidation) => {
if invalidation.provider.endpoint != configured.endpoint {
return Err(SubscriberError::Provider(
"external Flashblock invalidation came from an unexpected provider endpoint"
.into(),
));
}
if self.latest_preconfirmation.as_ref().is_some_and(|latest| {
latest.provider == invalidation.provider
&& latest.payload_id == Some(invalidation.payload_id)
}) {
self.invalidate_preconfirmation_snapshot();
self.last_external_flashblock_snapshot = None;
}
}
}
Ok(())
}
#[cfg(feature = "raw-flashblocks-json")]
fn validate_external_flashblock_sequence(
&self,
snapshot: &FlashblockSnapshot,
) -> Result<bool, SubscriberError> {
let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else {
if snapshot.flashblock.index != Some(0) {
return Err(SubscriberError::Provider(
"external Flashblock payload generation must begin at index zero".into(),
));
}
return Ok(false);
};
if previous.flashblock.provider == snapshot.flashblock.provider
&& previous.flashblock.payload_id == snapshot.flashblock.payload_id
{
let previous_index = previous
.flashblock
.index
.expect("validated indexed snapshot");
let current_index = snapshot
.flashblock
.index
.expect("validated indexed snapshot");
if current_index == previous_index {
if previous == snapshot {
return Ok(true);
}
return Err(SubscriberError::Provider(
"external Flashblock repeated the same index with conflicting content".into(),
));
}
if current_index < previous_index {
return Err(SubscriberError::Provider(format!(
"external Flashblock index regressed from {previous_index} to {current_index}"
)));
}
if current_index > previous_index.saturating_add(1) {
return Err(SubscriberError::Provider(format!(
"external Flashblock index skipped from {previous_index} to {current_index}"
)));
}
if current_index == previous_index.saturating_add(1)
&& !previous.flashblock.same_base_identity(&snapshot.flashblock)
{
return Err(SubscriberError::Provider(
"external Flashblock base identity changed within one payload generation"
.into(),
));
}
if current_index == previous_index.saturating_add(1)
&& !snapshot
.flashblock
.transaction_hashes
.starts_with(&previous.flashblock.transaction_hashes)
{
return Err(SubscriberError::Provider(
"external Flashblock cumulative transaction membership changed its prior prefix"
.into(),
));
}
let prior_transaction_count =
u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX);
if current_index == previous_index.saturating_add(1)
&& snapshot.logs.iter().any(|log| {
log.transaction_index
.is_some_and(|index| index < prior_transaction_count)
})
{
return Err(SubscriberError::Provider(
"external Flashblock delta log does not belong to a newly appended transaction"
.into(),
));
}
} else if snapshot.flashblock.index != Some(0) {
return Err(SubscriberError::Provider(
"external Flashblock payload generation must begin at index zero".into(),
));
}
Ok(false)
}
async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> {
self.flashblocks_rpc_metrics.pending_block_requests = self
.flashblocks_rpc_metrics
.pending_block_requests
.saturating_add(1);
let pending = self
.fetch_op_pending_block()
.await
.map_err(PendingFlashblockPollError::into_subscriber)?
.ok_or_else(|| {
SubscriberError::Provider(
"provider returned no pending block during Flashblocks preflight".into(),
)
})?;
self.certify_op_pending_parent(&pending)
.await
.map_err(PendingFlashblockPollError::into_subscriber)?;
for filter in filters {
self.flashblocks_rpc_metrics.pending_log_requests = self
.flashblocks_rpc_metrics
.pending_log_requests
.saturating_add(1);
self.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
.get_logs(
&filter
.clone()
.from_block(BlockNumberOrTag::Latest)
.to_block(BlockNumberOrTag::Pending),
)
.await
.map_err(provider_error)?;
}
self.flashblocks_rpc_metrics.pending_receipt_requests = self
.flashblocks_rpc_metrics
.pending_receipt_requests
.saturating_add(1);
let _: serde_json::Value = self
.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
.raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,))
.await
.map_err(provider_error)?;
Ok(())
}
async fn certify_op_pending_parent(
&mut self,
pending: &N::BlockResponse,
) -> Result<N::HeaderResponse, PendingFlashblockPollError> {
let pending_header = pending.header();
let pending_number = pending_header.number();
let parent_hash = pending_header.parent_hash();
if pending_number == 0 || parent_hash.is_zero() {
return Err(PendingFlashblockPollError::Integrity(
SubscriberError::Provider(
"OP pending block omitted a certifiable canonical parent".into(),
),
));
}
self.flashblocks_rpc_metrics.canonical_head_requests = self
.flashblocks_rpc_metrics
.canonical_head_requests
.saturating_add(1);
let parent = self
.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
.get_block_by_hash(parent_hash)
.await
.map_err(pending_flashblock_request_error)?
.ok_or_else(|| {
PendingFlashblockPollError::Request(SubscriberError::Provider(
"Flashblocks provider returned no exact OP pending parent block".into(),
))
})?;
let parent_header = parent.header();
if parent_header.hash() != parent_hash
|| parent_header.number().checked_add(1) != Some(pending_number)
{
return Err(PendingFlashblockPollError::Integrity(
SubscriberError::Provider(
"OP pending block does not extend its exact certified parent".into(),
),
));
}
Ok(parent_header.clone())
}
async fn fetch_op_pending_block(
&mut self,
) -> Result<Option<N::BlockResponse>, PendingFlashblockPollError> {
let state_provider = self
.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider);
let value: Option<serde_json::Value> = state_provider
.raw_request(
Cow::Borrowed("eth_getBlockByNumber"),
(BlockNumberOrTag::Pending, true),
)
.await
.map_err(pending_flashblock_request_error)?;
value
.map(normalize_op_pending_block::<N>)
.transpose()
.map_err(PendingFlashblockPollError::Integrity)
}
async fn ensure_chain_id(&mut self) -> Result<u64, SubscriberError> {
if let Some(chain_id) = self.chain_id {
return Ok(chain_id);
}
let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?;
self.chain_id = Some(chain_id);
Ok(chain_id)
}
fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> {
if self.config.preconfirmations == PreconfirmationMode::Disabled {
if self.uses_external_flashblock_updates() {
return Err(SubscriberError::InvalidConfig(
"external Flashblock updates require preconfirmations to be preferred or required",
));
}
return Ok(());
}
if self.uses_external_flashblock_updates() {
return Ok(());
}
if self.provider_ref.is_none() {
return Err(SubscriberError::InvalidConfig(
"Flashblocks require a stable provider ref from a pinned provider lease",
));
}
let Some(chain_id) = self.chain_id else {
return Ok(());
};
match flashblocks_adapter(chain_id) {
Some(FlashblocksAdapter::NativeSubscriptions)
if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub
&& self.config.preconfirmations == PreconfirmationMode::Required =>
{
return Err(SubscriberError::Unsupported(
"Base Flashblocks require pubsub for newFlashblocks and pendingLogs",
));
}
Some(FlashblocksAdapter::NativeSubscriptions) => {}
Some(_) => {}
None if self.config.preconfirmations == PreconfirmationMode::Required => {
return Err(SubscriberError::Unsupported(
"Flashblocks are currently implemented for Base and OP chains",
));
}
None => {}
}
Ok(())
}
pub async fn reconcile_interest_owner(
&mut self,
epoch: &SubscriberOwnerEpoch,
through: BlockRef,
) -> Result<SubscriberOwnerProgress, SubscriberOwnerError>
where
P: Clone,
{
self.reconcile_interest_owners(std::slice::from_ref(epoch), through)
.await?
.pop()
.ok_or(SubscriberOwnerError::NotStaged)
}
pub async fn reconcile_interest_owners(
&mut self,
epochs: &[SubscriberOwnerEpoch],
through: BlockRef,
) -> Result<Vec<SubscriberOwnerProgress>, SubscriberOwnerError>
where
P: Clone,
{
if epochs.is_empty() {
return Ok(Vec::new());
}
self.ensure_chain_id().await?;
let mut seen = HashSet::new();
let mut plans = Vec::with_capacity(epochs.len());
for epoch in epochs {
if !seen.insert(epoch.clone()) {
continue;
}
let entry = self
.owned_interests
.iter()
.find(|entry| {
entry.epoch.as_ref() == Some(epoch)
&& entry.state == SubscriberOwnerState::Staged
})
.ok_or(SubscriberOwnerError::NotStaged)?;
let position = entry
.progress
.as_ref()
.map(|progress| &progress.through)
.or(entry.baseline.as_ref())
.ok_or(SubscriberOwnerError::MissingBaseline)?;
let baseline = position.number;
if through.number < baseline {
return Err(SubscriberOwnerError::ProgressRegression {
current: baseline,
target: through.number,
});
}
let from_block = baseline
.checked_add(1)
.ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?;
if through.number == baseline && through.hash != position.hash {
return Err(SubscriberOwnerError::ProgressConflict {
number: baseline,
current_hash: position.hash,
target_hash: through.hash,
});
}
if through.number == from_block
&& through
.parent_hash
.is_some_and(|parent| parent != position.hash)
{
return Err(SubscriberOwnerError::ProgressConflict {
number: baseline,
current_hash: position.hash,
target_hash: through.parent_hash.expect("checked as present above"),
});
}
if entry
.interests
.iter()
.any(|interest| !matches!(interest, ReactiveInterest::Logs(_)))
{
return Err(SubscriberOwnerError::UnsupportedPostBlockInterest);
}
plans.push(SubscriberOwnerReconcilePlan {
epoch: epoch.clone(),
interests: entry.interests.clone(),
retained: *position,
from_block,
});
}
self.ensure_streams().await?;
let provider = self.provider.clone();
let filters = merged_owner_reconcile_filters(&plans, through.number);
let retained = plans.iter().map(|plan| plan.retained).collect();
let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect();
let fetch = fetch_owner_catchup::<P, N>(
provider,
filters,
retained,
through,
SubscriberOwnerCatchupOptions {
target_preverified: false,
max_logs: self.config.max_pending_records,
max_log_bytes: self.config.max_backfill_log_bytes,
max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
},
);
let SubscriberOwnerCatchup { logs, certified } =
self.drive_reconcile_fetch(fetch, &target_epochs).await?;
let records = logs
.into_iter()
.map(|log| log_input_record(log, InputSource::Backfill))
.collect();
let mut routed_records = Vec::new();
for record in dedupe_records(sort_records(records)).map_err(|error| {
SubscriberError::InvalidBackfill(format!(
"conflicting duplicate owner catch-up record: {error}"
))
})? {
let block_number = match &record.input {
ReactiveInput::Log(log) => log
.block_number
.expect("bulk catch-up logs were validated before commit"),
_ => unreachable!("bulk owner catch-up contains log records only"),
};
let owners: Vec<SubscriberOwnerEpoch> = plans
.iter()
.filter(|plan| block_number >= plan.from_block)
.filter(|plan| {
plan.interests
.iter()
.any(|interest| interest_matches(interest, &record.input))
})
.map(|plan| plan.epoch.clone())
.collect();
if !owners.is_empty() {
routed_records.push((record, owners));
}
}
self.ensure_pending_record_capacity(
routed_records.len(),
"owner reconciliation historical records",
)?;
self.pending_backfills.retain(|queued| {
queued
.epoch
.as_ref()
.is_none_or(|epoch| !target_epochs.contains(epoch))
});
for (record, owners) in routed_records {
self.enqueue_owner_record_for_owners_unmerged(record, owners);
}
self.promote_reconcile_owner_records(&target_epochs);
self.seed_reconciled_filter_anchors(&plans, certified.number);
let stream_revision = self.stream_revision;
let mut progress = Vec::with_capacity(plans.len());
for plan in plans {
let item = SubscriberOwnerProgress {
owner: plan.epoch.clone(),
through: certified,
};
let entry = self
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(&plan.epoch))
.expect("bulk reconcile holds exclusive access after epoch preflight");
entry.progress = Some(item.clone());
entry.progress_stream_revision = Some(stream_revision);
progress.push(item);
}
Ok(progress)
}
async fn drive_reconcile_fetch<T, F>(
&mut self,
fetch: F,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) -> Result<T, SubscriberOwnerError>
where
F: Future<Output = Result<T, SubscriberOwnerError>>,
{
if !matches!(&self.state, AlloySubscriberState::Active(_)) {
return fetch.await;
}
let mut fetch = Box::pin(fetch);
loop {
let event = {
let live = Box::pin(self.next_event());
match select(fetch, live).await {
Either::Left((result, pending_live)) => {
drop(pending_live);
return result;
}
Either::Right((event, pending_fetch)) => {
fetch = pending_fetch;
event
}
}
};
let event = event?.ok_or_else(|| {
SubscriberError::Provider(
"Alloy subscriber streams ended during owner reconcile".to_owned(),
)
})?;
self.buffer_reconcile_event_for_owners(&event, target_epochs);
self.enqueue_event_excluding_owners(event, target_epochs);
self.check_resource_error()?;
}
}
pub async fn next_scoped_batch_or<C, F>(
&mut self,
control: Pin<&mut F>,
) -> Result<SubscriberDriverPoll<C, N>, SubscriberError>
where
C: Send,
F: Future<Output = C> + Send,
{
let batch = self.next_scoped_batch();
match select(control, batch).await {
Either::Left((control, pending_batch)) => {
drop(pending_batch);
Ok(SubscriberDriverPoll::Control(control))
}
Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch),
}
}
pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> {
Box::pin(async {
self.check_resource_error()?;
if self.chain_id.is_none()
&& (!self.pending_records.is_empty()
|| !self.pending_chain_controls.is_empty()
|| !self.pending_backfills.is_empty()
|| !self.interests.is_empty())
{
self.ensure_chain_id().await?;
}
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
self.ensure_streams().await?;
self.check_resource_error()?;
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
self.drain_pending_backfills().await?;
self.check_resource_error()?;
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
if self.interests.is_empty() {
return Ok(None);
}
loop {
let Some(event) = self.next_event().await? else {
return Ok(None);
};
self.enqueue_event(event);
self.check_resource_error()?;
if let Some(batch) = self.drain_next_scoped_batch() {
return Ok(Some(batch));
}
}
})
}
async fn ensure_streams(&mut self) -> Result<(), SubscriberError> {
if !self.sources_dirty {
return Ok(());
}
if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() {
self.bump_stream_revision();
self.sources_dirty = false;
return Ok(());
}
let desired = self.stream_sources()?;
let missing: Vec<SubscriberStreamSource> = match &self.state {
AlloySubscriberState::Active(streams) => desired
.iter()
.filter(|source| !streams.contains_source(source))
.cloned()
.collect(),
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(),
};
for source in missing {
let stream = match self.connect_source_stream(source.clone()).await {
Ok(stream) => stream,
Err(error)
if source.is_flashblocks()
&& self.config.preconfirmations == PreconfirmationMode::Preferred =>
{
tracing::warn!(
stream = source.label(),
error = %error,
"Flashblocks source unavailable; canonical delivery remains active"
);
if self.config.reconnect.enabled {
self.schedule_flashblock_reconnect(
source,
self.config.reconnect.retry_delay,
);
}
continue;
}
Err(error) => return Err(error),
};
self.install_source_stream(source.clone(), stream);
if self.source_requires_backfill(&source) {
self.queue_source_backfill(source);
}
}
while let Some(source) = self.pending_source_backfills.front().cloned() {
let desired_and_live = desired.iter().any(|item| item.same_key(&source))
&& matches!(
&self.state,
AlloySubscriberState::Active(streams) if streams.contains_source(&source)
);
if !desired_and_live {
self.pending_source_backfills.pop_front();
continue;
}
let event = self.backfill_reconnected_source(&source).await?;
self.pending_source_backfills.pop_front();
if let Some(event) = event {
self.enqueue_event(event);
}
}
if let AlloySubscriberState::Active(streams) = &mut self.state {
streams.retain_sources(&desired);
if streams.is_empty() {
self.state = AlloySubscriberState::Empty;
}
}
self.bump_stream_revision();
self.sources_dirty = false;
self.retire_unreferenced_filters();
Ok(())
}
fn install_source_stream(
&mut self,
source: SubscriberStreamSource,
stream: BoxStream<'static, SubscriberEvent<N>>,
) {
match &mut self.state {
AlloySubscriberState::Active(streams) => {
if streams.contains_source(&source) {
return;
}
streams.push(source, stream);
}
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
let mut streams = SubscriberStreams::new();
streams.push(source, stream);
self.state = AlloySubscriberState::Active(streams);
}
}
self.bump_stream_revision();
}
fn schedule_flashblock_reconnect(
&mut self,
source: SubscriberStreamSource,
first_delay: Duration,
) {
if self
.pending_flashblock_reconnect_sources
.iter()
.any(|pending| pending.same_key(&source))
{
return;
}
self.pending_flashblock_reconnect_sources
.push(source.clone());
self.pending_flashblock_reconnects
.push(flashblock_reconnect_future(
self.provider.root().clone(),
source,
self.config.max_batch_size,
self.config.reconnect.clone(),
first_delay,
self.config.flashblock_poll_interval,
));
}
fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) {
if !self.config.reconnect.enabled {
return;
}
self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay);
}
fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool {
matches!(source, SubscriberStreamSource::PubSubLog { id, .. }
if self.last_seen_log_blocks.contains_key(id))
}
fn queue_source_backfill(&mut self, source: SubscriberStreamSource) {
if !self
.pending_source_backfills
.iter()
.any(|pending| pending.same_key(&source))
{
self.pending_source_backfills.push_back(source);
}
}
async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> {
while let Some(queued) = self.pending_backfills.front() {
let epoch = queued.epoch.clone();
let owner = queued.owner.clone();
let owner_exists = match (&epoch, &owner) {
(Some(epoch), _) => self.interest_owner_state(epoch).is_some(),
(None, Some(owner)) => self.owner_interests(owner).is_some(),
(None, None) => true,
};
if !owner_exists {
self.pending_backfills.pop_front();
continue;
}
let filters = queued.filters.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() {
let certified = if let Some(retained) = backfill.retained_anchor() {
let actual =
fetch_provider_block_ref::<P, N>(&self.provider, retained.number).await?;
if !block_ref_satisfies_expected(&actual, retained) {
return Err(SubscriberError::InvalidBackfill(format!(
"retained anchor {}:{:?} conflicts with provider block {}:{:?}",
retained.number, retained.hash, actual.number, actual.hash
)));
}
if to_block < retained.number {
return Err(SubscriberError::InvalidBackfill(format!(
"backfill upper bound {to_block} precedes retained anchor {}",
retained.number
)));
}
Some(actual)
} else {
None
};
self.pending_backfills.pop_front();
for filter in &filters {
let source_id = self.log_source_id(filter);
if let Some(certified) = certified {
self.last_seen_log_blocks
.entry(source_id)
.and_modify(|anchor| *anchor = (*anchor).max(certified.number))
.or_insert(certified.number);
}
}
if owner.is_none()
&& let Some(certified) = certified
{
self.pending_chain_controls
.push_back(global_backfill_barrier(backfill, certified));
}
if !self.pending_chain_controls.is_empty() {
break;
}
continue;
}
let through = fetch_provider_block_ref::<P, N>(&self.provider, to_block).await?;
let request_filters =
merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number);
let retained = backfill.retained_anchor().copied().into_iter().collect();
let SubscriberOwnerCatchup {
mut logs,
certified,
} = fetch_owner_catchup::<&P, N>(
&self.provider,
request_filters,
retained,
through,
SubscriberOwnerCatchupOptions {
target_preverified: true,
max_logs: self.config.max_pending_records,
max_log_bytes: self.config.max_backfill_log_bytes,
max_requests_in_flight: self.config.max_reconcile_requests_in_flight,
},
)
.await
.map_err(lazy_backfill_error)?;
logs.sort_by_key(|log| {
(
log.block_number.unwrap_or_default(),
log.transaction_index.unwrap_or_default(),
log.log_index.unwrap_or_default(),
)
});
logs.dedup();
self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?;
self.pending_backfills.pop_front();
if let Some(epoch) = epoch.as_ref() {
self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill));
} else if let Some(owner) = owner.as_ref() {
self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill);
} else {
self.enqueue_backfilled_logs(logs, None, None, Some(backfill));
self.pending_chain_controls
.push_back(global_backfill_barrier(backfill, certified));
}
for filter in &filters {
let source_id = self.log_source_id(filter);
let anchor = self
.last_seen_log_blocks
.entry(source_id)
.or_insert(certified.number);
*anchor = (*anchor).max(certified.number);
}
if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() {
break;
}
}
Ok(())
}
fn stream_sources(&mut self) -> Result<Vec<SubscriberStreamSource>, SubscriberError> {
let sources = match resolve_subscriber_transport(self.mode)? {
SubscriberTransport::PubSub => self.pubsub_stream_sources(),
SubscriberTransport::Polling => self.polling_stream_sources(),
};
#[cfg(feature = "raw-flashblocks-json")]
let sources = {
let mut sources = sources;
if self.external_flashblock_update_channel_opened {
sources.push(SubscriberStreamSource::ExternalFlashblockUpdates);
}
sources
};
Ok(sources)
}
fn pubsub_stream_sources(&mut self) -> Vec<SubscriberStreamSource> {
let mut sources = Vec::new();
let inherited_anchor = self.last_seen_log_blocks.values().copied().min();
for filter in self.log_stream_filters() {
let id = self.log_source_id(&filter);
if let Some(anchor) = inherited_anchor {
self.last_seen_log_blocks.entry(id).or_insert(anchor);
}
sources.push(SubscriberStreamSource::PubSubLog { id, filter });
}
if needs_pending_hash_stream(&self.interests) {
sources.push(SubscriberStreamSource::PubSubPendingHashes);
}
if needs_header_block_stream(&self.interests) {
if !self.uses_external_flashblock_updates()
&& self.config.preconfirmations != PreconfirmationMode::Disabled
&& self.chain_id.and_then(flashblocks_adapter).is_some()
{
sources.push(SubscriberStreamSource::CanonicalHeadPolling);
} else {
sources.push(SubscriberStreamSource::PubSubBlockHeaders);
}
}
if self.config.preconfirmations != PreconfirmationMode::Disabled
&& !self.uses_external_flashblock_updates()
{
match self.chain_id.and_then(flashblocks_adapter) {
Some(FlashblocksAdapter::NativeSubscriptions) => {
sources.push(SubscriberStreamSource::BaseFlashblocks);
for filter in self.log_stream_filters() {
let id = self.log_source_id(&filter);
sources.push(SubscriberStreamSource::BasePendingLog { id, filter });
}
}
Some(FlashblocksAdapter::PendingStatePolling) => {
sources.push(SubscriberStreamSource::OpPendingFlashblocks);
}
None => {}
}
}
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);
}
if self.config.preconfirmations != PreconfirmationMode::Disabled
&& !self.uses_external_flashblock_updates()
&& self.chain_id.and_then(flashblocks_adapter)
== Some(FlashblocksAdapter::PendingStatePolling)
{
sources.push(SubscriberStreamSource::OpPendingFlashblocks);
}
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::BasePendingLog { id, filter } => {
self.connect_base_pending_log_stream(id, filter).await
}
SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await,
SubscriberStreamSource::OpPendingFlashblocks => {
self.connect_op_flashblock_tick_stream()
}
SubscriberStreamSource::CanonicalHeadPolling => {
self.connect_canonical_head_tick_stream()
}
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
}
#[cfg(feature = "raw-flashblocks-json")]
SubscriberStreamSource::ExternalFlashblockUpdates => {
let receiver = self.external_flashblock_updates.take().ok_or_else(|| {
SubscriberError::Provider(
"external Flashblock update channel receiver is unavailable".into(),
)
})?;
let updates = stream::unfold(receiver, |mut receiver| async move {
receiver
.recv()
.await
.map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver))
});
Ok(stream_with_termination(
updates,
SubscriberStreamSource::ExternalFlashblockUpdates,
))
}
}
}
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_base_pending_log_stream(
&mut self,
id: usize,
filter: Filter,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
let source = SubscriberStreamSource::BasePendingLog {
id,
filter: filter.clone(),
};
let params = base_pending_log_filter(&filter)?;
let stream = self
.provider
.subscribe::<_, Log>(("pendingLogs", params))
.channel_size(self.config.max_batch_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
Ok(stream_with_termination(stream, source))
}
#[cfg(not(feature = "reactive-ws"))]
{
let _ = (id, filter);
Err(SubscriberError::Unsupported(
"Base Flashblocks require the reactive-ws feature",
))
}
}
async fn connect_base_flashblock_stream(
&mut self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
#[cfg(feature = "reactive-ws")]
{
let stream = self
.provider
.subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
.channel_size(self.config.max_batch_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(SubscriberEvent::BaseFlashblock);
Ok(stream_with_termination(
stream,
SubscriberStreamSource::BaseFlashblocks,
))
}
#[cfg(not(feature = "reactive-ws"))]
{
Err(SubscriberError::Unsupported(
"Base Flashblocks require the reactive-ws feature",
))
}
}
fn connect_canonical_head_tick_stream(
&self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let stream = stream::unfold(interval, |mut interval| async move {
interval.tick().await;
Some((SubscriberEvent::CanonicalHeadTick, interval))
});
Ok(stream_with_termination(
stream,
SubscriberStreamSource::CanonicalHeadPolling,
))
}
fn connect_op_flashblock_tick_stream(
&self,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError> {
let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval;
let mut interval =
tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let stream = stream::unfold(interval, |mut interval| async move {
interval.tick().await;
Some((SubscriberEvent::OpFlashblockTick, interval))
});
Ok(stream_with_termination(
stream,
SubscriberStreamSource::OpPendingFlashblocks,
))
}
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 ready = match &mut self.state {
AlloySubscriberState::Active(streams)
if !self.pending_flashblock_reconnects.is_empty() =>
{
let stream_event = Box::pin(streams.next());
let reconnect = Box::pin(self.pending_flashblock_reconnects.next());
match select(reconnect, stream_event).await {
Either::Left((reconnect, pending_event)) => {
drop(pending_event);
let Some((source, result)) = reconnect else {
continue;
};
SubscriberReady::FlashblockReconnect(source, result)
}
Either::Right((event, pending_reconnect)) => {
drop(pending_reconnect);
SubscriberReady::Event(event)
}
}
}
AlloySubscriberState::Active(streams) => {
SubscriberReady::Event(streams.next().await)
}
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty
if !self.pending_flashblock_reconnects.is_empty() =>
{
let Some((source, result)) = self.pending_flashblock_reconnects.next().await
else {
continue;
};
SubscriberReady::FlashblockReconnect(source, result)
}
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => {
return Ok(None);
}
};
let event = match ready {
SubscriberReady::Event(event) => event,
SubscriberReady::FlashblockReconnect(source, result) => {
self.pending_flashblock_reconnect_sources
.retain(|pending| !pending.same_key(&source));
match result {
Ok(stream) => {
self.install_source_stream(source, stream);
}
Err(error)
if self.config.preconfirmations == PreconfirmationMode::Preferred =>
{
tracing::warn!(
stream = source.label(),
error = %error,
"Flashblocks reconnect window exhausted; canonical delivery remains active"
);
self.reschedule_preferred_flashblock(source);
}
Err(error) => return Err(error),
}
continue;
}
};
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 source.is_external_flashblocks() {
#[cfg(feature = "raw-flashblocks-json")]
{
self.external_flashblock_update_channel_opened = false;
}
if let AlloySubscriberState::Active(streams) = &mut self.state {
streams
.entries
.retain(|entry| !entry.source.is_external_flashblocks());
streams.normalize_next_index();
}
self.invalidate_preconfirmation_snapshot();
if self.config.preconfirmations == PreconfirmationMode::Required {
return Err(SubscriberError::Provider(
"required external Flashblock update channel closed".into(),
));
}
return Ok(Some(SubscriberEvent::FlashblockInvalidated));
}
if source.is_flashblocks() {
self.invalidate_flashblock_generation();
return Ok(Some(SubscriberEvent::FlashblockInvalidated));
}
self.sources_dirty = true;
self.bump_stream_revision();
if let Some(backfill_event) = self.reconnect_source_stream(source).await? {
self.sources_dirty = false;
if let Some(backfill_event) =
self.normalize_flashblock_event(backfill_event).await?
{
self.verify_event_log_blocks(&backfill_event).await?;
return Ok(Some(backfill_event));
}
}
self.sources_dirty = false;
}
event => {
let Some(event) = self.normalize_flashblock_event(event).await? else {
continue;
};
self.verify_event_log_blocks(&event).await?;
return Ok(Some(event));
}
}
}
}
fn invalidate_flashblock_generation(&mut self) {
self.pending_records
.retain(|record| record.scope != SubscriberInputScope::Preconfirmed);
self.pending_preconfirmation_invalidation = true;
self.reset_flashblock_tracking();
if let Some(provider) = self.provider_ref.as_mut() {
provider.generation = provider.generation.saturating_add(1);
}
if let AlloySubscriberState::Active(streams) = &mut self.state {
streams
.entries
.retain(|entry| !entry.source.is_flashblocks());
streams.normalize_next_index();
}
let reconnect_sources = self
.stream_sources()
.unwrap_or_default()
.into_iter()
.filter(SubscriberStreamSource::is_flashblocks)
.collect::<Vec<_>>();
self.pending_flashblock_reconnects.clear();
self.pending_flashblock_reconnect_sources.clear();
if self.config.preconfirmations == PreconfirmationMode::Required
|| self.config.reconnect.enabled
{
for source in reconnect_sources {
self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay);
}
}
self.sources_dirty = false;
self.bump_stream_revision();
}
async fn normalize_flashblock_event(
&mut self,
event: SubscriberEvent<N>,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
match event {
#[cfg(feature = "raw-flashblocks-json")]
SubscriberEvent::ExternalFlashblockUpdate(queued) => {
let provider = queued.update.provider().clone();
match self.ingest_flashblock_update(queued.update) {
Ok(()) => {
let _ = queued.acknowledgement.send(Ok(()));
Ok(Some(SubscriberEvent::FlashblockObserved))
}
Err(error)
if self.config.preconfirmations == PreconfirmationMode::Preferred =>
{
let recoverable_capacity =
matches!(error, SubscriberError::ResourceExhausted(_));
if !recoverable_capacity
&& let Some(configured) = self.external_flashblocks_provider.as_mut()
&& configured.endpoint == provider.endpoint
{
self.rejected_external_flashblock_generation = Some(
self.rejected_external_flashblock_generation
.map_or(provider.generation, |rejected| {
rejected.max(provider.generation)
}),
);
configured.generation = configured
.generation
.max(provider.generation.saturating_add(1));
}
self.invalidate_preconfirmation_snapshot();
self.last_external_flashblock_snapshot = None;
let _ = queued
.acknowledgement
.send(Err(FlashblockUpdateChannelError::Rejected));
tracing::warn!(
provider = %provider.endpoint,
generation = provider.generation,
error = %error,
"external Flashblock update rejected; canonical delivery remains active"
);
Ok(Some(SubscriberEvent::FlashblockInvalidated))
}
Err(error) => {
let _ = queued
.acknowledgement
.send(Err(FlashblockUpdateChannelError::Rejected));
Err(error)
}
}
}
SubscriberEvent::BasePendingLog { source_id, log } => {
let block_number = log.block_number.ok_or_else(|| {
SubscriberError::Provider(
"pendingLogs item is missing its pending block number".into(),
)
})?;
let transaction_hash = log.transaction_hash.ok_or_else(|| {
SubscriberError::Provider(
"pendingLogs item is missing its transaction hash".into(),
)
})?;
let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| {
flashblock.block_number == block_number
&& flashblock.contains_transaction(&transaction_hash)
});
let Some(flashblock) = matching.cloned() else {
if self
.latest_preconfirmation
.as_ref()
.is_some_and(|latest| block_number < latest.block_number)
{
return Ok(None);
}
if self.unmatched_pending_logs.len() >= self.config.max_pending_records {
return Err(SubscriberError::ResourceExhausted(
"unmatched pendingLogs exceeded max_pending_records".into(),
));
}
self.unmatched_pending_logs.push_back((source_id, log));
return Ok(None);
};
let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?;
Ok(Some(if logs.is_empty() {
SubscriberEvent::FlashblockObserved
} else {
SubscriberEvent::PreconfirmedLogs { flashblock, logs }
}))
}
SubscriberEvent::BaseFlashblock(payload) => {
let (flashblock, recover_pending_snapshot) =
self.accept_base_flashblock(payload)?;
let mut logs = Vec::new();
let mut retained = VecDeque::new();
while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() {
let transaction_hash = log.transaction_hash;
if log.block_number == Some(flashblock.block_number)
&& transaction_hash
.as_ref()
.is_some_and(|hash| flashblock.contains_transaction(hash))
{
let _ = source_id;
logs.push(log);
} else if log
.block_number
.is_some_and(|number| number >= flashblock.block_number)
{
retained.push_back((source_id, log));
} else {
}
}
self.unmatched_pending_logs = retained;
let indexed_recovery = recover_pending_snapshot.then(|| {
let payload_id = flashblock
.payload_id
.expect("indexed recovery carries a payload id");
let index = flashblock.index.expect("indexed recovery carries an index");
let last_diff = self
.base_flashblock_transactions
.as_ref()
.filter(|(known_payload, known_index, _, _)| {
*known_payload == payload_id && *known_index == index
})
.map(|(_, _, _, last_diff)| last_diff.clone())
.unwrap_or_default();
(payload_id, index, last_diff)
});
if recover_pending_snapshot {
if let Some(event) = self
.fetch_pending_flashblock(indexed_recovery)
.await
.map_err(PendingFlashblockPollError::into_subscriber)?
{
return Ok(Some(event));
}
self.invalidate_flashblock_generation();
return Ok(Some(SubscriberEvent::FlashblockInvalidated));
}
let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
Ok(Some(if logs.is_empty() {
SubscriberEvent::FlashblockObserved
} else {
SubscriberEvent::PreconfirmedLogs { flashblock, logs }
}))
}
SubscriberEvent::OpFlashblockTick => self.poll_op_pending_flashblock().await,
SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await,
SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
let logs = self.filter_preconfirmed_logs(&flashblock, logs)?;
Ok(Some(if logs.is_empty() {
SubscriberEvent::FlashblockObserved
} else {
SubscriberEvent::PreconfirmedLogs { flashblock, logs }
}))
}
SubscriberEvent::FlashblockObserved => Ok(None),
event => Ok(Some(event)),
}
}
async fn fetch_certified_canonical_head(
&mut self,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
tokio::time::timeout(
self.config.canonical_head_request_timeout,
self.fetch_certified_canonical_head_inner(),
)
.await
.map_err(|_| {
SubscriberError::Provider(format!(
"canonical head certification timed out after {:?}",
self.config.canonical_head_request_timeout
))
})?
}
async fn fetch_certified_canonical_head_inner(
&mut self,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
if self.chain_id.and_then(flashblocks_adapter)
== Some(FlashblocksAdapter::PendingStatePolling)
{
if !self.reserve_flashblock_rpc_methods(2) {
return Ok(None);
}
self.flashblocks_rpc_metrics.pending_block_requests = self
.flashblocks_rpc_metrics
.pending_block_requests
.saturating_add(1);
let pending = self
.fetch_op_pending_block()
.await
.map_err(PendingFlashblockPollError::into_subscriber)?
.ok_or_else(|| {
SubscriberError::Provider(
"provider returned no OP pending block while certifying its parent".into(),
)
})?;
let header = self
.certify_op_pending_parent(&pending)
.await
.map_err(PendingFlashblockPollError::into_subscriber)?;
let certified = BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
if self.last_certified_canonical_head.as_ref() == Some(&certified) {
return Ok(None);
}
self.last_certified_canonical_head = Some(certified);
return Ok(Some(SubscriberEvent::BlockHeader(header)));
}
self.flashblocks_rpc_metrics.canonical_head_requests = self
.flashblocks_rpc_metrics
.canonical_head_requests
.saturating_add(1);
let block = self
.provider
.get_block_by_number(BlockNumberOrTag::Latest)
.await
.map_err(provider_error)?
.ok_or_else(|| {
SubscriberError::Provider(
"provider returned no latest block while certifying canonical head".into(),
)
})?;
let header = block.header();
if header.hash().is_zero() {
return Err(SubscriberError::Provider(
"provider returned a placeholder hash for the latest canonical head".into(),
));
}
let certified = BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
if self.last_certified_canonical_head.as_ref() == Some(&certified) {
return Ok(None);
}
self.last_certified_canonical_head = Some(certified);
Ok(Some(SubscriberEvent::BlockHeader(header.clone())))
}
fn accept_base_flashblock(
&mut self,
payload: BaseFlashblockWirePayload,
) -> Result<(FlashblockRef, bool), SubscriberError> {
let provider = self.provider_ref.clone().ok_or({
SubscriberError::InvalidConfig(
"Flashblocks require a stable provider ref from a pinned provider lease",
)
})?;
let (flashblock, recover_pending_snapshot) = match payload {
BaseFlashblockWirePayload::Indexed(payload) => {
if payload.index == 0 {
let base = payload.base.clone().ok_or_else(|| {
SubscriberError::Provider(
"indexed newFlashblocks item zero omitted its base header".into(),
)
})?;
self.base_flashblock_header = Some((payload.payload_id, base));
}
let base = self
.base_flashblock_header
.as_ref()
.filter(|(payload_id, _)| *payload_id == payload.payload_id)
.map(|(_, base)| base);
let block_number = base.map(|base| base.block_number).or_else(|| {
payload
.metadata
.as_ref()
.map(|metadata| metadata.block_number)
});
let block_number = block_number.ok_or_else(|| {
SubscriberError::Provider(
"indexed newFlashblocks payload omitted both base and metadata block number"
.into(),
)
})?;
let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?;
let transaction_hashes = match self.base_flashblock_transactions.as_mut() {
Some((known_payload, known_index, transactions, last_diff))
if *known_payload == payload.payload_id =>
{
if payload.index < *known_index {
return self
.latest_preconfirmation
.clone()
.map(|flashblock| (flashblock, false))
.ok_or_else(|| {
SubscriberError::Provider(
"regressive indexed Flashblock arrived without an active snapshot"
.into(),
)
});
}
if payload.index == *known_index {
if *last_diff != diff_transactions {
return Err(SubscriberError::Provider(
"conflicting duplicate indexed Flashblock payload".into(),
));
}
} else {
if diff_transactions
.iter()
.any(|hash| transactions.contains(hash))
{
return Err(SubscriberError::Provider(
"indexed Flashblock repeated a transaction from an earlier diff"
.into(),
));
}
transactions.extend(diff_transactions.iter().copied());
*known_index = payload.index;
*last_diff = diff_transactions;
}
transactions.clone()
}
_ => {
self.base_flashblock_transactions = Some((
payload.payload_id,
payload.index,
diff_transactions.clone(),
diff_transactions.clone(),
));
diff_transactions
}
};
let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
let transactions_root = payload
.diff
.transactions_root
.and_then(non_placeholder_hash);
let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash));
let state_root = non_placeholder_hash(payload.diff.state_root);
let timestamp = base.map(|base| base.timestamp);
let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas);
let beneficiary = base.and_then(|base| base.beneficiary);
let prevrandao = base
.and_then(|base| base.prevrandao)
.and_then(non_placeholder_hash);
let gas_limit = base.and_then(|base| base.gas_limit);
let content_hash = flashblock_content_hash(FlashblockContentCommitment {
provider: &provider,
payload_id: Some(payload.payload_id),
index: Some(payload.index),
block_number,
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes: &transaction_hashes,
timestamp,
base_fee_per_gas,
beneficiary,
prevrandao,
gas_limit,
});
let flashblock = FlashblockRef {
provider,
payload_id: Some(payload.payload_id),
index: Some(payload.index),
block_number,
content_hash,
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes,
timestamp,
base_fee_per_gas,
beneficiary,
prevrandao,
gas_limit,
};
if let Some(previous) = self.latest_preconfirmation.as_ref()
&& previous.same_payload(&flashblock)
&& previous.index == flashblock.index
&& previous.content_hash != flashblock.content_hash
{
return Err(SubscriberError::Provider(
"conflicting duplicate indexed Flashblock content".into(),
));
}
let recover = match self.latest_preconfirmation.as_ref() {
Some(previous) if previous.same_payload(&flashblock) => {
if let (Some(previous), Some(current)) = (previous.index, flashblock.index)
{
if current < previous {
return Ok((flashblock, false));
}
current > previous.saturating_add(1)
} else {
false
}
}
Some(_) => payload.index != 0,
None => payload.index != 0,
};
(flashblock, recover)
}
BaseFlashblockWirePayload::Block(payload) => {
let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?;
let parent_hash = non_placeholder_hash(payload.parent_hash);
let state_root = non_placeholder_hash(payload.state_root);
let transactions_root = payload.transactions_root.and_then(non_placeholder_hash);
let partial_block_hash = non_placeholder_hash(payload.hash);
let prevrandao = payload.mix_hash.and_then(non_placeholder_hash);
let content_hash = flashblock_content_hash(FlashblockContentCommitment {
provider: &provider,
payload_id: None,
index: None,
block_number: payload.number,
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes: &transaction_hashes,
timestamp: Some(payload.timestamp),
base_fee_per_gas: payload.base_fee_per_gas,
beneficiary: payload.miner,
prevrandao,
gas_limit: payload.gas_limit,
});
let flashblock = FlashblockRef {
provider,
payload_id: None,
index: None,
block_number: payload.number,
content_hash,
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes,
timestamp: Some(payload.timestamp),
base_fee_per_gas: payload.base_fee_per_gas,
beneficiary: payload.miner,
prevrandao,
gas_limit: payload.gas_limit,
};
if let Some(previous) = self.latest_preconfirmation.as_ref()
&& flashblock.same_payload(previous)
&& flashblock.content_hash != previous.content_hash
&& !flashblock.is_cumulative_successor_of(previous)
{
return Err(SubscriberError::Provider(
"cumulative Flashblock transaction membership is non-monotonic".into(),
));
}
(flashblock, false)
}
};
Ok((flashblock, recover_pending_snapshot))
}
async fn poll_op_pending_flashblock(
&mut self,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
match self.fetch_pending_flashblock(None).await {
Ok(event) => {
self.consecutive_flashblock_poll_failures = 0;
Ok(event)
}
Err(PendingFlashblockPollError::Request(error)) => {
self.flashblocks_rpc_metrics.failed_requests = self
.flashblocks_rpc_metrics
.failed_requests
.saturating_add(1);
self.consecutive_flashblock_poll_failures =
self.consecutive_flashblock_poll_failures.saturating_add(1);
if self.consecutive_flashblock_poll_failures
>= self.config.max_consecutive_flashblock_poll_failures
{
return Err(error);
}
tracing::warn!(
consecutive_failures = self.consecutive_flashblock_poll_failures,
failure_limit = self.config.max_consecutive_flashblock_poll_failures,
error = %error,
"Optimism pending-state Flashblocks request failed; retrying on the next tick"
);
Ok(None)
}
Err(PendingFlashblockPollError::Integrity(error)) => Err(error),
}
}
async fn fetch_pending_flashblock(
&mut self,
indexed_recovery: Option<(FixedBytes<8>, u64, Vec<B256>)>,
) -> Result<Option<SubscriberEvent<N>>, PendingFlashblockPollError> {
let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
== Some(FlashblocksAdapter::PendingStatePolling);
if samples_pending_range {
let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len());
if !self.reserve_flashblock_rpc_methods(fixed_methods) {
return Ok(None);
}
}
let state_provider = if samples_pending_range {
self.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
} else {
&self.provider
};
let latest = if samples_pending_range {
None
} else {
self.flashblocks_rpc_metrics.canonical_head_requests = self
.flashblocks_rpc_metrics
.canonical_head_requests
.saturating_add(1);
Some(
state_provider
.get_block_number()
.await
.map_err(pending_flashblock_request_error)?,
)
};
self.flashblocks_rpc_metrics.pending_block_requests = self
.flashblocks_rpc_metrics
.pending_block_requests
.saturating_add(1);
let pending_block = if samples_pending_range {
self.fetch_op_pending_block().await?
} else {
self.provider
.get_block_by_number(BlockNumberOrTag::Pending)
.await
.map_err(pending_flashblock_request_error)?
};
let Some(block) = pending_block else {
if self.config.preconfirmations == PreconfirmationMode::Required {
return Err(PendingFlashblockPollError::Request(
SubscriberError::Provider(
"Flashblocks provider returned no pending block".into(),
),
));
}
return Ok(None);
};
let latest = if samples_pending_range {
self.certify_op_pending_parent(&block).await?.number()
} else {
latest.expect("non-OP pending recovery fetched a canonical height")
};
let header = block.header();
if header.number() <= latest {
return Ok(None);
}
let provider = self.provider_ref.clone().ok_or({
PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig(
"Flashblocks require a stable provider ref from a pinned provider lease",
))
})?;
let parent_hash = Some(header.parent_hash());
let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() {
hashes.to_vec()
} else if let Some(transactions) = block.transactions().as_transactions() {
transactions
.iter()
.map(|transaction| transaction.tx_hash())
.collect()
} else {
Vec::new()
};
let state_root = non_placeholder_hash(header.state_root());
let transactions_root = non_placeholder_hash(header.transactions_root());
let partial_block_hash = non_placeholder_hash(header.hash());
let prevrandao = header.mix_hash().and_then(non_placeholder_hash);
let content_hash = flashblock_content_hash(FlashblockContentCommitment {
provider: &provider,
payload_id: None,
index: None,
block_number: header.number(),
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes: &transaction_hashes,
timestamp: Some(header.timestamp()),
base_fee_per_gas: header.base_fee_per_gas(),
beneficiary: Some(header.beneficiary()),
prevrandao,
gas_limit: Some(header.gas_limit()),
});
let flashblock = FlashblockRef {
provider,
payload_id: None,
index: None,
block_number: header.number(),
content_hash,
partial_block_hash,
parent_hash,
state_root,
transactions_root,
transaction_hashes,
timestamp: Some(header.timestamp()),
base_fee_per_gas: header.base_fee_per_gas(),
beneficiary: Some(header.beneficiary()),
prevrandao,
gas_limit: Some(header.gas_limit()),
};
if samples_pending_range
&& self
.latest_preconfirmation
.as_ref()
.is_some_and(|previous| !previous.same_payload(&flashblock))
{
self.invalidate_preconfirmation_snapshot();
}
if let Some((payload_id, index, last_diff)) = indexed_recovery {
self.base_flashblock_transactions = Some((
payload_id,
index,
flashblock.transaction_hashes.clone(),
last_diff,
));
}
let repeats_pending_snapshot = self
.latest_preconfirmation
.as_ref()
.is_some_and(|previous| previous == &flashblock);
if repeats_pending_snapshot && !samples_pending_range {
return Ok(None);
}
if let Some(previous) = self.latest_preconfirmation.as_ref()
&& flashblock.same_payload(previous)
&& !flashblock.is_cumulative_successor_of(previous)
{
if samples_pending_range {
self.invalidate_preconfirmation_snapshot();
return Ok(Some(SubscriberEvent::FlashblockInvalidated));
}
return Err(PendingFlashblockPollError::Integrity(
SubscriberError::Provider(
"sampled cumulative Flashblock transaction membership is non-monotonic".into(),
),
));
}
let mut logs = self.fetch_pending_logs(flashblock.block_number).await?;
if samples_pending_range {
let (mut receipt_logs, completed_receipts, unavailable_receipts) =
self.fetch_pending_transaction_receipts(&flashblock).await?;
logs.append(&mut receipt_logs);
logs.retain(|log| log.block_number == Some(flashblock.block_number));
for log in &logs {
let transaction_hash = log.transaction_hash.ok_or_else(|| {
PendingFlashblockPollError::Integrity(SubscriberError::Provider(
"pre-confirmed log is missing its transaction hash".into(),
))
})?;
if !flashblock.contains_transaction(&transaction_hash) {
self.flashblocks_rpc_metrics.raced_samples =
self.flashblocks_rpc_metrics.raced_samples.saturating_add(1);
return Ok(None);
}
}
let logs = self
.filter_preconfirmed_logs(&flashblock, logs)
.map_err(PendingFlashblockPollError::Integrity)?;
self.preconfirmed_unavailable_receipts
.extend(unavailable_receipts);
for transaction_hash in &completed_receipts {
self.preconfirmed_unavailable_receipts
.remove(transaction_hash);
}
self.preconfirmed_receipted_transactions
.extend(completed_receipts);
if repeats_pending_snapshot && logs.is_empty() {
return Ok(None);
}
return Ok(Some(if logs.is_empty() {
SubscriberEvent::FlashblockObserved
} else {
SubscriberEvent::PreconfirmedLogs { flashblock, logs }
}));
}
let logs = self
.filter_preconfirmed_logs(&flashblock, logs)
.map_err(PendingFlashblockPollError::Integrity)?;
Ok(Some(if logs.is_empty() {
SubscriberEvent::FlashblockObserved
} else {
SubscriberEvent::PreconfirmedLogs { flashblock, logs }
}))
}
async fn fetch_pending_logs(
&mut self,
pending_block_number: u64,
) -> Result<Vec<Log>, PendingFlashblockPollError> {
let mut logs = Vec::new();
let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
== Some(FlashblocksAdapter::PendingStatePolling);
let state_provider = if samples_pending_range {
self.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider)
} else {
&self.provider
};
for filter in self.log_stream_filters() {
self.flashblocks_rpc_metrics.pending_log_requests = self
.flashblocks_rpc_metrics
.pending_log_requests
.saturating_add(1);
let filter = if samples_pending_range {
filter
.from_block(pending_block_number)
.to_block(BlockNumberOrTag::Pending)
} else {
filter
.from_block(BlockNumberOrTag::Pending)
.to_block(BlockNumberOrTag::Pending)
};
logs.extend(
state_provider
.get_logs(&filter)
.await
.map_err(pending_flashblock_request_error)?,
);
}
if samples_pending_range {
logs.retain(|log| log.block_number == Some(pending_block_number));
}
Ok(logs)
}
async fn fetch_pending_transaction_receipts(
&mut self,
flashblock: &FlashblockRef,
) -> Result<(Vec<Log>, Vec<B256>, Vec<B256>), PendingFlashblockPollError> {
let receipt_allowance = self.pending_receipt_request_allowance();
let receipt_limit = self
.config
.max_pending_transaction_receipts_per_tick
.min(receipt_allowance);
if receipt_limit == 0 {
return Ok((Vec::new(), Vec::new(), Vec::new()));
}
let mut transaction_hashes = Vec::with_capacity(receipt_limit);
for transaction_hash in &flashblock.transaction_hashes {
if !self
.preconfirmed_receipted_transactions
.contains(transaction_hash)
&& !self
.preconfirmed_unavailable_receipts
.contains(transaction_hash)
{
transaction_hashes.push(*transaction_hash);
if transaction_hashes.len() == receipt_limit {
break;
}
}
}
if transaction_hashes.len() < receipt_limit {
for transaction_hash in &flashblock.transaction_hashes {
if self
.preconfirmed_unavailable_receipts
.contains(transaction_hash)
{
transaction_hashes.push(*transaction_hash);
if transaction_hashes.len() == receipt_limit {
break;
}
}
}
}
if transaction_hashes.is_empty() {
return Ok((Vec::new(), Vec::new(), Vec::new()));
}
let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len());
debug_assert!(reserved, "receipt allowance must remain reserved until use");
if !reserved {
return Ok((Vec::new(), Vec::new(), Vec::new()));
}
self.flashblocks_rpc_metrics.pending_receipt_requests = self
.flashblocks_rpc_metrics
.pending_receipt_requests
.saturating_add(transaction_hashes.len() as u64);
let state_provider = self
.flashblocks_state_provider
.as_ref()
.unwrap_or(&self.provider);
let client = state_provider.client();
let mut batch = BatchRequest::new(client);
let mut waiters = Vec::with_capacity(transaction_hashes.len());
for transaction_hash in transaction_hashes {
let waiter = batch
.add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,))
.map_err(pending_flashblock_request_error)?;
waiters.push((transaction_hash, waiter));
}
batch
.send()
.await
.map_err(pending_flashblock_request_error)?;
let mut logs = Vec::new();
let mut completed = Vec::new();
let mut unavailable = Vec::new();
for (transaction_hash, waiter) in waiters {
let value = waiter.await.map_err(pending_flashblock_request_error)?;
if let Some(mut receipt_logs) =
normalize_pending_transaction_receipt(transaction_hash, value)
.map_err(PendingFlashblockPollError::Integrity)?
{
self.flashblocks_rpc_metrics.pending_receipts_completed = self
.flashblocks_rpc_metrics
.pending_receipts_completed
.saturating_add(1);
logs.append(&mut receipt_logs);
completed.push(transaction_hash);
} else {
self.flashblocks_rpc_metrics.pending_receipts_unavailable = self
.flashblocks_rpc_metrics
.pending_receipts_unavailable
.saturating_add(1);
unavailable.push(transaction_hash);
}
}
Ok((logs, completed, unavailable))
}
fn pending_receipt_request_allowance(&mut self) -> usize {
self.prune_flashblock_rpc_request_times();
let rolling_capacity = self
.config
.max_flashblock_rpc_requests_per_second
.saturating_sub(self.flashblock_rpc_request_times.len());
rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity())
}
fn pending_receipt_requests_per_tick_capacity(&self) -> usize {
let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
self.pending_receipt_requests_per_second_capacity()
.checked_div(ticks_per_second)
.unwrap_or(0)
}
fn pending_receipt_requests_per_second_capacity(&self) -> usize {
let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1);
let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos);
let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX);
let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len());
let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick);
if needs_header_block_stream(&self.interests) {
let canonical_interval_nanos =
self.config.canonical_head_poll_interval.as_nanos().max(1);
let canonical_ticks = Duration::from_secs(1)
.as_nanos()
.div_ceil(canonical_interval_nanos);
let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX);
reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2));
}
self.config
.max_flashblock_rpc_requests_per_second
.saturating_sub(reserved_methods)
}
fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool {
self.prune_flashblock_rpc_request_times();
if self
.flashblock_rpc_request_times
.len()
.saturating_add(methods)
> self.config.max_flashblock_rpc_requests_per_second
{
return false;
}
let now = Instant::now();
for _ in 0..methods {
self.flashblock_rpc_request_times.push_back(now);
}
true
}
fn prune_flashblock_rpc_request_times(&mut self) {
let now = Instant::now();
while self
.flashblock_rpc_request_times
.front()
.is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1))
{
self.flashblock_rpc_request_times.pop_front();
}
}
fn filter_preconfirmed_logs(
&mut self,
flashblock: &FlashblockRef,
mut logs: Vec<Log>,
) -> Result<Vec<Log>, SubscriberError> {
let samples_pending_range = self.chain_id.and_then(flashblocks_adapter)
== Some(FlashblocksAdapter::PendingStatePolling);
if self
.latest_preconfirmation
.as_ref()
.is_some_and(|previous| !previous.same_payload(flashblock))
{
self.invalidate_preconfirmation_snapshot();
}
if self
.latest_preconfirmation
.as_ref()
.is_none_or(|previous| !previous.same_payload(flashblock))
{
self.preconfirmed_seen_logs.clear();
}
if let Some(previous) = self.latest_preconfirmation.as_ref()
&& previous.same_payload(flashblock)
&& let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index)
&& current_index < previous_index
{
return Ok(Vec::new());
}
self.latest_preconfirmation = Some(flashblock.clone());
logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index));
let mut filtered = Vec::new();
for mut log in logs {
if log.removed || log.block_number != Some(flashblock.block_number) {
return Err(SubscriberError::Provider(
"pre-confirmed log disagrees with its Flashblock snapshot".into(),
));
}
let transaction_hash = log.transaction_hash.ok_or_else(|| {
SubscriberError::Provider(
"pre-confirmed log is missing its transaction hash".into(),
)
})?;
let log_index = log.log_index.ok_or_else(|| {
SubscriberError::Provider("pre-confirmed log is missing its log index".into())
})?;
let transaction_index =
flashblock
.transaction_index(&transaction_hash)
.ok_or_else(|| {
SubscriberError::Provider(
"pre-confirmed log transaction is absent from the cumulative Flashblock"
.into(),
)
})?;
if log
.transaction_index
.is_some_and(|reported| reported != transaction_index)
{
return Err(SubscriberError::Provider(
"pre-confirmed log transaction index disagrees with cumulative membership"
.into(),
));
}
let reported_hash = log.block_hash.and_then(non_placeholder_hash);
if !samples_pending_range
&& let Some(reported) = reported_hash
&& reported != flashblock.content_hash
&& flashblock
.partial_block_hash
.is_some_and(|expected| reported != expected)
{
return Err(SubscriberError::Provider(
"pre-confirmed log partial block hash disagrees with its Flashblock snapshot"
.into(),
));
}
log.block_hash = Some(flashblock.content_hash);
log.block_timestamp = flashblock.timestamp.or(log.block_timestamp);
log.transaction_index = Some(transaction_index);
if self
.preconfirmed_seen_logs
.insert((transaction_hash, log_index))
&& log_matches_any_interest(&log, &self.interests)
{
filtered.push(log);
}
}
Ok(filtered)
}
async fn verify_event_log_blocks(
&mut self,
event: &SubscriberEvent<N>,
) -> Result<(), SubscriberError> {
if !self.config.verify_log_block_context {
return Ok(());
}
match event {
SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await,
SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => {
for log in logs {
self.verify_log_block_context(log).await?;
}
Ok(())
}
#[cfg(feature = "raw-flashblocks-json")]
SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()),
SubscriberEvent::BlockHeader(_)
| SubscriberEvent::PendingHash(_)
| SubscriberEvent::PendingHashes(_)
| SubscriberEvent::BasePendingLog { .. }
| SubscriberEvent::BaseFlashblock(_)
| SubscriberEvent::OpFlashblockTick
| SubscriberEvent::CanonicalHeadTick
| SubscriberEvent::PreconfirmedLogs { .. }
| SubscriberEvent::FlashblockInvalidated
| SubscriberEvent::FlashblockObserved
| SubscriberEvent::StreamTerminated(_) => Ok(()),
}
}
async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> {
if log.removed {
return Ok(());
}
let number = log.block_number.ok_or_else(|| {
SubscriberError::Provider(
"canonical log is missing its block number during context verification".into(),
)
})?;
let hash = log.block_hash.ok_or_else(|| {
SubscriberError::Provider(
"canonical log is missing its block hash during context verification".into(),
)
})?;
let key = (number, hash);
if self.verified_log_blocks.contains_key(&key) {
return Ok(());
}
let provider = self
.log_verification_provider
.as_ref()
.unwrap_or(&self.provider);
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(number))
.await
.map_err(provider_error)?
.ok_or_else(|| {
SubscriberError::Provider(format!(
"canonical log block {number} is unavailable during context verification"
))
})?;
let header = block.header();
let verified = BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
if verified.number != number
|| verified.hash != hash
|| log
.block_timestamp
.is_some_and(|timestamp| verified.timestamp != Some(timestamp))
{
return Err(SubscriberError::Provider(format!(
"canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity"
)));
}
self.verified_log_blocks.insert(key, verified);
self.verified_log_block_order.push_back(key);
let capacity = self.config.reconnect.dedupe_window.max(1);
while self.verified_log_block_order.len() > capacity {
if let Some(evicted) = self.verified_log_block_order.pop_front() {
self.verified_log_blocks.remove(&evicted);
}
}
Ok(())
}
fn enqueue_event(&mut self, event: SubscriberEvent<N>) {
self.enqueue_event_with_excluded_owners(event, None);
}
fn buffer_reconcile_event_for_owners(
&mut self,
event: &SubscriberEvent<N>,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) {
match event {
SubscriberEvent::Log { log, .. } => {
self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs)
}
SubscriberEvent::BackfilledLogs { logs, .. } => {
for log in logs {
self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs);
}
}
SubscriberEvent::Logs(logs) => {
for log in logs {
self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs);
}
}
#[cfg(feature = "raw-flashblocks-json")]
SubscriberEvent::ExternalFlashblockUpdate(_) => {}
SubscriberEvent::BlockHeader(_)
| SubscriberEvent::PendingHash(_)
| SubscriberEvent::PendingHashes(_)
| SubscriberEvent::BasePendingLog { .. }
| SubscriberEvent::BaseFlashblock(_)
| SubscriberEvent::OpFlashblockTick
| SubscriberEvent::CanonicalHeadTick
| SubscriberEvent::PreconfirmedLogs { .. }
| SubscriberEvent::FlashblockInvalidated
| SubscriberEvent::FlashblockObserved
| SubscriberEvent::StreamTerminated(_) => {}
}
}
fn buffer_reconcile_log_for_owners(
&mut self,
log: &Log,
source: InputSource,
target_epochs: &HashSet<SubscriberOwnerEpoch>,
) {
let record = self.with_chain_id(log_input_record(log.clone(), source));
let owners = self
.staged_owners_for_record(&record)
.into_iter()
.filter(|owner| target_epochs.contains(owner))
.collect::<Vec<_>>();
if !owners.is_empty() {
self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners });
}
}
fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet<SubscriberOwnerEpoch>) {
let mut retained = VecDeque::new();
while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() {
let mut promoted = Vec::new();
buffered.owners.retain(|owner| {
if target_epochs.contains(owner) {
promoted.push(owner.clone());
false
} else {
true
}
});
if promoted.is_empty() {
retained.push_back(buffered);
continue;
}
let promoted_record = if buffered.owners.is_empty() {
buffered.record
} else {
let record = buffered.record.clone();
retained.push_back(buffered);
record
};
self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted);
}
self.pending_reconcile_owner_records = retained;
}
fn seed_reconciled_filter_anchors(
&mut self,
plans: &[SubscriberOwnerReconcilePlan<N>],
through: u64,
) {
for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) {
let Some(source_id) = self.log_source_ids.get(&filter).copied() else {
continue;
};
let anchor = self
.last_seen_log_blocks
.entry(source_id)
.or_insert(through);
*anchor = (*anchor).max(through);
}
}
fn enqueue_event_excluding_owners(
&mut self,
event: SubscriberEvent<N>,
excluded: &HashSet<SubscriberOwnerEpoch>,
) {
self.enqueue_event_with_excluded_owners(event, Some(excluded));
}
fn enqueue_event_with_excluded_owners(
&mut self,
event: SubscriberEvent<N>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
match event {
SubscriberEvent::Log { source_id, log } => {
if log_matches_any_interest(&log, &self.interests) {
let record = log_input_record(log, InputSource::Subscription);
self.note_log_block(source_id, &record);
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
SubscriberEvent::BackfilledLogs { source_id, logs } => {
self.enqueue_backfilled_logs_with_excluded_owners(
logs,
Some(source_id),
None,
None,
excluded,
);
}
SubscriberEvent::Logs(logs) => {
for log in logs {
if log_matches_any_interest(&log, &self.interests) {
self.enqueue_record_with_excluded_owners(
log_input_record(log, InputSource::Poll),
excluded,
);
}
}
}
SubscriberEvent::BlockHeader(header) => {
if needs_header_block_stream(&self.interests) {
let record = block_header_input_record::<N>(header);
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
SubscriberEvent::PendingHash(hash) => {
let record = pending_hash_input_record::<N>(hash, InputSource::Subscription);
self.enqueue_record_with_excluded_owners(record, excluded);
}
SubscriberEvent::PendingHashes(hashes) => {
for hash in hashes {
self.enqueue_record_with_excluded_owners(
pending_hash_input_record::<N>(hash, InputSource::Poll),
excluded,
);
}
}
SubscriberEvent::PreconfirmedLogs { flashblock, logs } => {
for log in logs {
let record = self
.with_chain_id(preconfirmed_log_input_record::<N>(log, flashblock.clone()));
self.push_pending_record(SubscriberInputRecord {
record,
scope: SubscriberInputScope::Preconfirmed,
});
}
}
SubscriberEvent::FlashblockInvalidated => {
self.pending_preconfirmation_invalidation = true;
}
SubscriberEvent::BasePendingLog { .. }
| SubscriberEvent::BaseFlashblock(_)
| SubscriberEvent::OpFlashblockTick
| SubscriberEvent::CanonicalHeadTick
| SubscriberEvent::FlashblockObserved => {}
#[cfg(feature = "raw-flashblocks-json")]
SubscriberEvent::ExternalFlashblockUpdate(_) => {}
SubscriberEvent::StreamTerminated(_) => {}
}
}
fn enqueue_backfilled_logs(
&mut self,
logs: Vec<Log>,
source_id: Option<usize>,
owner: Option<&SubscriberOwnerEpoch>,
range: Option<SubscriberBackfill>,
) {
self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None);
}
fn enqueue_backfilled_logs_with_excluded_owners(
&mut self,
logs: Vec<Log>,
source_id: Option<usize>,
owner: Option<&SubscriberOwnerEpoch>,
range: Option<SubscriberBackfill>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
for log in logs {
if range.as_ref().is_some_and(|range| {
log.block_number.is_some_and(|block| {
block < range.start_block() || range.end_block().is_some_and(|end| block > end)
})
}) {
continue;
}
let matches = match owner {
Some(epoch) => self
.owned_interests
.iter()
.find(|entry| entry.epoch.as_ref() == Some(epoch))
.is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)),
None => log_matches_any_interest(&log, &self.interests),
};
if matches {
let record = log_input_record(log, InputSource::Backfill);
if let Some(epoch) = owner {
self.enqueue_owner_record(record, epoch.clone());
} else {
if let Some(source_id) = source_id {
self.note_log_block(source_id, &record);
}
self.enqueue_record_with_excluded_owners(record, excluded);
}
}
}
}
fn enqueue_compat_owner_backfilled_logs(
&mut self,
logs: Vec<Log>,
owner: &HandlerId,
range: SubscriberBackfill,
) {
let interests = self
.owned_interests
.iter()
.find(|entry| {
&entry.owner == owner
&& entry.epoch.is_none()
&& entry.state == SubscriberOwnerState::Active
})
.map(|entry| entry.interests.clone());
let Some(interests) = interests else {
return;
};
for log in logs {
if log.block_number.is_some_and(|block| {
block < range.start_block() || range.end_block().is_some_and(|end| block > end)
}) || !log_matches_any_interest(&log, &interests)
{
continue;
}
let record = log_input_record(log, InputSource::Backfill);
self.enqueue_compat_owner_record(record, owner.clone());
}
}
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> {
if matches!(
&self.state,
AlloySubscriberState::Active(streams) if streams.contains_source(&source)
) {
let backfill_event = self.backfill_reconnected_source(&source).await?;
self.pending_source_backfills
.retain(|pending| !pending.same_key(&source));
return Ok(backfill_event);
}
let stream = self.connect_source_stream(source.clone()).await?;
if !matches!(self.state, AlloySubscriberState::Active(_)) {
return Err(SubscriberError::Provider(
"Alloy subscriber state changed before reconnect completed".to_owned(),
));
}
self.install_source_stream(source.clone(), stream);
if self.source_requires_backfill(&source) {
self.queue_source_backfill(source.clone());
}
let backfill_event = self.backfill_reconnected_source(&source).await?;
self.pending_source_backfills
.retain(|pending| !pending.same_key(&source));
Ok(backfill_event)
}
async fn backfill_reconnected_source(
&mut self,
source: &SubscriberStreamSource,
) -> Result<Option<SubscriberEvent<N>>, SubscriberError> {
if source.is_flashblocks() {
return Ok(None);
}
let SubscriberStreamSource::PubSubLog { id, filter } = source else {
return Ok(None);
};
let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else {
return Ok(None);
};
let latest = self
.provider
.get_block_number()
.await
.map_err(provider_error)?;
if latest < from_block {
return Ok(None);
}
let logs = self
.provider
.get_logs(&filter.clone().from_block(from_block).to_block(latest))
.await
.map_err(provider_error)?;
Ok(Some(SubscriberEvent::BackfilledLogs {
source_id: *id,
logs,
}))
}
fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord<N>) {
if let Some(block) = record.context.block.as_ref() {
self.last_seen_log_blocks.insert(source_id, block.number);
}
}
fn enqueue_record_with_excluded_owners(
&mut self,
record: ReactiveInputRecord<N>,
excluded: Option<&HashSet<SubscriberOwnerEpoch>>,
) {
let record = self.with_chain_id(record);
let mut owners = self.staged_owners_for_record(&record);
if let Some(excluded) = excluded {
owners.retain(|owner| !excluded.contains(owner));
}
let canonical_duplicate = self.should_skip_recent_duplicate(&record);
let owners = self.filter_recent_owner_duplicates(&record, owners);
let compatibility_owners = self.compatibility_owners_for_record(&record);
let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners
.into_iter()
.partition(|owner| self.compatibility_owner_has_seen(&record, owner));
if canonical_duplicate {
if !owners.is_empty() {
self.push_pending_record(SubscriberInputRecord {
record: record.clone(),
scope: SubscriberInputScope::OwnerOnly { owners },
});
}
if !newly_served.is_empty() {
for owner in &newly_served {
self.remember_compatibility_owner_record(&record, owner);
}
self.push_pending_record(SubscriberInputRecord {
record,
scope: SubscriberInputScope::OwnerOnlyHandlers {
owners: newly_served,
},
});
}
return;
}
self.remember_record(&record);
for owner in already_served.iter().chain(&newly_served) {
self.remember_compatibility_owner_record(&record, owner);
}
self.push_pending_record(SubscriberInputRecord {
record,
scope: if already_served.is_empty() {
SubscriberInputScope::Canonical { owners }
} else {
SubscriberInputScope::CanonicalResidual {
owners,
excluded: already_served,
}
},
});
}
fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord<N>, owner: HandlerId) {
let record = self.with_chain_id(record);
if self.compatibility_owner_has_seen(&record, &owner) {
return;
}
self.remember_compatibility_owner_record(&record, &owner);
self.push_pending_record(SubscriberInputRecord {
record,
scope: SubscriberInputScope::OwnerOnlyHandlers {
owners: vec![owner],
},
});
}
fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord<N>) -> Vec<HandlerId> {
self.owned_interests
.iter()
.filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active)
.filter(|entry| {
entry
.interests
.iter()
.any(|interest| interest_matches(interest, &record.input))
})
.map(|entry| entry.owner.clone())
.collect()
}
fn compatibility_owner_has_seen(
&self,
record: &ReactiveInputRecord<N>,
owner: &HandlerId,
) -> bool {
should_dedupe_record(record)
&& self
.recent_compat_owner_input_ref_sets
.get(owner)
.is_some_and(|seen| seen.contains(&record.input_ref()))
}
fn remember_compatibility_owner_record(
&mut self,
record: &ReactiveInputRecord<N>,
owner: &HandlerId,
) {
if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
return;
}
let input_ref = record.input_ref();
let seen = self
.recent_compat_owner_input_ref_sets
.entry(owner.clone())
.or_default();
if !seen.insert(input_ref) {
return;
}
let recent = self
.recent_compat_owner_input_refs
.entry(owner.clone())
.or_default();
recent.push_back(input_ref);
while recent.len() > self.config.reconnect.dedupe_window {
if let Some(evicted) = recent.pop_front() {
seen.remove(&evicted);
}
}
}
fn enqueue_owner_record(
&mut self,
record: ReactiveInputRecord<N>,
owner: SubscriberOwnerEpoch,
) {
self.enqueue_owner_record_for_owners(record, vec![owner]);
}
fn enqueue_owner_record_for_owners(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) {
self.enqueue_owner_record_for_owners_inner(record, owners, true);
}
fn enqueue_owner_record_for_owners_unmerged(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) {
self.enqueue_owner_record_for_owners_inner(record, owners, false);
}
fn enqueue_owner_record_for_owners_inner(
&mut self,
record: ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
merge_pending: bool,
) {
let record = self.with_chain_id(record);
let owners = self.filter_recent_owner_duplicates(&record, owners);
if owners.is_empty() {
return;
}
if merge_pending
&& should_dedupe_record(&record)
&& self.config.reconnect.dedupe_window != 0
{
let input_ref = record.input_ref();
if let Some(pending) = self
.pending_records
.iter_mut()
.rev()
.find(|pending| pending.record.input_ref() == input_ref)
{
let pending_owners = match &mut pending.scope {
SubscriberInputScope::Canonical { owners }
| SubscriberInputScope::CanonicalResidual { owners, .. }
| SubscriberInputScope::OwnerOnly { owners } => Some(owners),
SubscriberInputScope::OwnerOnlyHandlers { .. }
| SubscriberInputScope::Preconfirmed => None,
};
if let Some(pending_owners) = pending_owners {
for owner in owners {
if !pending_owners.contains(&owner) {
pending_owners.push(owner);
}
}
return;
}
}
}
self.push_pending_record(SubscriberInputRecord {
record,
scope: SubscriberInputScope::OwnerOnly { owners },
});
}
fn push_pending_record(&mut self, record: SubscriberInputRecord<N>) {
if self.pending_record_count() >= self.config.max_pending_records {
self.note_resource_error(format!(
"pending record queues reached the configured limit of {}",
self.config.max_pending_records
));
return;
}
self.pending_records.push_back(record);
}
fn ensure_pending_record_capacity(
&mut self,
additional: usize,
operation: &str,
) -> Result<(), SubscriberError> {
let required = self.pending_record_count().saturating_add(additional);
if required > self.config.max_pending_records {
self.note_resource_error(format!(
"{operation} require {required} pending records, above the configured limit of {}",
self.config.max_pending_records
));
return self.check_resource_error();
}
Ok(())
}
fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord<N>) {
if self.pending_record_count() >= self.config.max_pending_records {
self.note_resource_error(format!(
"pending record queues reached the configured limit of {}",
self.config.max_pending_records
));
return;
}
self.pending_reconcile_owner_records.push_back(record);
}
fn pending_record_count(&self) -> usize {
self.pending_records
.len()
.saturating_add(self.pending_reconcile_owner_records.len())
}
fn note_resource_error(&mut self, message: String) {
if self.resource_error.is_none() {
self.resource_error = Some(message);
}
}
fn check_resource_error(&self) -> Result<(), SubscriberError> {
match &self.resource_error {
Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())),
None => Ok(()),
}
}
fn with_chain_id(&self, mut record: ReactiveInputRecord<N>) -> ReactiveInputRecord<N> {
record.context.chain_id = self.chain_id;
if self.config.verify_log_block_context
&& let ReactiveInput::Log(log) = &record.input
&& !log.removed
&& let (Some(number), Some(hash)) = (log.block_number, log.block_hash)
&& let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied()
{
record.context.block = Some(verified);
record.context.chain_status = ChainStatus::Included {
block: verified,
confirmations: 0,
};
}
record
}
fn staged_owners_for_record(
&self,
record: &ReactiveInputRecord<N>,
) -> Vec<SubscriberOwnerEpoch> {
self.owned_interests
.iter()
.filter(|entry| entry.state == SubscriberOwnerState::Staged)
.filter(|entry| {
entry
.interests
.iter()
.any(|interest| interest_matches(interest, &record.input))
})
.filter_map(|entry| entry.epoch.clone())
.collect()
}
fn filter_recent_owner_duplicates(
&mut self,
record: &ReactiveInputRecord<N>,
owners: Vec<SubscriberOwnerEpoch>,
) -> Vec<SubscriberOwnerEpoch> {
if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
return owners;
}
let input_ref = record.input_ref();
let window = self.config.reconnect.dedupe_window;
owners
.into_iter()
.filter(|owner| {
let seen = self
.recent_owner_input_ref_sets
.entry(owner.clone())
.or_default();
if !seen.insert(input_ref) {
return false;
}
let recent = self
.recent_owner_input_refs
.entry(owner.clone())
.or_default();
recent.push_back(input_ref);
while recent.len() > window {
if let Some(evicted) = recent.pop_front() {
seen.remove(&evicted);
}
}
true
})
.collect()
}
fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord<N>) -> bool {
if !should_dedupe_record(record) {
return false;
}
self.recent_input_ref_set.contains(&record.input_ref())
}
fn remember_record(&mut self, record: &ReactiveInputRecord<N>) {
if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 {
return;
}
let input_ref = record.input_ref();
if !self.recent_input_ref_set.insert(input_ref) {
return;
}
self.recent_input_refs.push_back(input_ref);
while self.recent_input_refs.len() > self.config.reconnect.dedupe_window {
if let Some(evicted) = self.recent_input_refs.pop_front() {
self.recent_input_ref_set.remove(&evicted);
}
}
}
}
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 flashblock_reconnect_future<N>(
provider: RootProvider<N>,
source: SubscriberStreamSource,
channel_size: usize,
reconnect: SubscriberReconnectConfig,
first_delay: Duration,
flashblock_poll_interval: Duration,
) -> FlashblockReconnectFuture<N>
where
N: Network + 'static,
{
Box::pin(async move {
if !reconnect.enabled {
let error = SubscriberError::Provider(format!(
"Alloy subscriber {} stream terminated and reconnect is disabled",
source.label()
));
return (source, Err(error));
}
let mut attempts = 0_usize;
let mut delay = first_delay;
let mut retry_delay = reconnect.retry_delay;
loop {
attempts = attempts.saturating_add(1);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
match connect_flashblock_source_once(
&provider,
source.clone(),
channel_size,
flashblock_poll_interval,
)
.await
{
Ok(stream) => return (source, Ok(stream)),
Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => {
return (
source.clone(),
Err(SubscriberError::Provider(format!(
"Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}",
source.label()
))),
);
}
Err(error) => {
tracing::warn!(
stream = source.label(),
attempts,
error = %error,
"Flashblocks reconnect attempt failed"
);
delay = retry_delay;
retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay);
}
}
}
})
}
async fn connect_flashblock_source_once<N>(
provider: &RootProvider<N>,
source: SubscriberStreamSource,
channel_size: usize,
flashblock_poll_interval: Duration,
) -> Result<BoxStream<'static, SubscriberEvent<N>>, SubscriberError>
where
N: Network + 'static,
{
#[cfg(not(feature = "reactive-ws"))]
let _ = provider;
match source {
SubscriberStreamSource::BasePendingLog { id, filter } => {
#[cfg(feature = "reactive-ws")]
{
let source = SubscriberStreamSource::BasePendingLog {
id,
filter: filter.clone(),
};
let params = base_pending_log_filter(&filter)?;
let stream = provider
.subscribe::<_, Log>(("pendingLogs", params))
.channel_size(channel_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log });
Ok(stream_with_termination(stream, source))
}
#[cfg(not(feature = "reactive-ws"))]
{
let _ = (id, filter, channel_size);
Err(SubscriberError::Unsupported(
"Base Flashblocks require the reactive-ws feature",
))
}
}
SubscriberStreamSource::BaseFlashblocks => {
#[cfg(feature = "reactive-ws")]
{
let stream = provider
.subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",))
.channel_size(channel_size.max(1))
.await
.map_err(provider_error)?
.into_stream()
.map(SubscriberEvent::BaseFlashblock);
Ok(stream_with_termination(
stream,
SubscriberStreamSource::BaseFlashblocks,
))
}
#[cfg(not(feature = "reactive-ws"))]
{
let _ = channel_size;
Err(SubscriberError::Unsupported(
"Base Flashblocks require the reactive-ws feature",
))
}
}
SubscriberStreamSource::OpPendingFlashblocks => {
let first_tick = tokio::time::Instant::now();
let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let stream = stream::unfold(interval, |mut interval| async move {
interval.tick().await;
Some((SubscriberEvent::OpFlashblockTick, interval))
});
Ok(stream_with_termination(
stream,
SubscriberStreamSource::OpPendingFlashblocks,
))
}
source => Err(SubscriberError::InvalidConfig(match source {
SubscriberStreamSource::PubSubLog { .. }
| SubscriberStreamSource::CanonicalHeadPolling
| SubscriberStreamSource::PubSubPendingHashes
| SubscriberStreamSource::PubSubBlockHeaders
| SubscriberStreamSource::PollingLog { .. }
| SubscriberStreamSource::PollingPendingHashes => {
"Flashblocks reconnect received a canonical source"
}
SubscriberStreamSource::BasePendingLog { .. }
| SubscriberStreamSource::BaseFlashblocks
| SubscriberStreamSource::OpPendingFlashblocks => unreachable!(),
#[cfg(feature = "raw-flashblocks-json")]
SubscriberStreamSource::ExternalFlashblockUpdates => {
"Flashblocks reconnect cannot own an application-managed source"
}
})),
}
}
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_json_rpc::{RequestPacket, ResponsePacket};
use alloy_provider::ProviderBuilder;
use alloy_rpc_client::RpcClient;
use alloy_transport::{TransportError, TransportFut, mock::Asserter};
use std::task::{Context, Poll};
use tower::Service;
#[derive(Clone, Debug)]
struct NeverRespondingTransport;
impl Service<RequestPacket> for NeverRespondingTransport {
type Response = ResponsePacket;
type Error = TransportError;
type Future = TransportFut<'static>;
fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: RequestPacket) -> Self::Future {
Box::pin(futures::future::pending())
}
}
fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload {
BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload {
payload_id: FixedBytes::repeat_byte(0x11),
index: 0,
base: Some(BaseFlashblockBase {
parent_hash: B256::repeat_byte(100),
block_number: 101,
timestamp: 1_700_000_101,
gas_limit: Some(30_000_000),
base_fee_per_gas: Some(7),
beneficiary: Some(Address::repeat_byte(0xcb)),
prevrandao: Some(B256::repeat_byte(0x77)),
}),
diff: BaseFlashblockDiff {
state_root,
block_hash: B256::ZERO,
transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))],
transactions_root: None,
},
metadata: None,
})
}
#[test]
fn duplicate_flashblock_transaction_membership_is_rejected() {
let transaction = format!("{:#x}", B256::repeat_byte(0x41));
let transactions = vec![
serde_json::Value::String(transaction.clone()),
serde_json::Value::String(transaction),
];
assert!(matches!(
flashblock_transaction_hashes(&transactions),
Err(SubscriberError::Provider(ref message)) if message.contains("duplicate")
));
}
#[test]
fn conflicting_duplicate_indexed_flashblock_is_rejected() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber
.accept_base_flashblock(indexed_flashblock(
B256::repeat_byte(0x41),
B256::repeat_byte(0xa1),
))
.expect("first indexed preview");
assert!(matches!(
subscriber.accept_base_flashblock(indexed_flashblock(
B256::repeat_byte(0x42),
B256::repeat_byte(0xa2),
)),
Err(SubscriberError::Provider(ref message))
if message.contains("conflicting duplicate")
));
}
#[tokio::test]
async fn duplicate_index_with_changed_commitment_is_rejected() {
let transaction = B256::repeat_byte(0x41);
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
transaction,
B256::repeat_byte(0xa1),
)))
.await
.expect("first indexed preview");
let BaseFlashblockWirePayload::Indexed(mut conflicting) =
indexed_flashblock(transaction, B256::repeat_byte(0xa1))
else {
unreachable!()
};
conflicting.diff.state_root = B256::repeat_byte(0xbb);
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
BaseFlashblockWirePayload::Indexed(conflicting),
))
.await,
Err(SubscriberError::Provider(ref message))
if message.contains("conflicting duplicate indexed Flashblock content")
));
}
#[tokio::test]
async fn indexed_gap_recovery_seeds_later_cumulative_membership() {
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let transaction_c = B256::repeat_byte(0x43);
let transaction_d = B256::repeat_byte(0x44);
let asserter = Asserter::new();
asserter.push_success(&100_u64);
let pending = rpc_block(101, B256::ZERO).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
transaction_c,
]),
);
asserter.push_success(&Some(pending));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
transaction_a,
B256::repeat_byte(0xa1),
)))
.await
.expect("index zero preview");
let BaseFlashblockWirePayload::Indexed(mut gap) =
indexed_flashblock(transaction_c, B256::repeat_byte(0xa3))
else {
unreachable!()
};
gap.index = 2;
gap.base = None;
gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
BaseFlashblockWirePayload::Indexed(gap),
))
.await
.expect("the missing index is recovered from pending state");
let BaseFlashblockWirePayload::Indexed(mut next) =
indexed_flashblock(transaction_d, B256::repeat_byte(0xa4))
else {
unreachable!()
};
next.index = 3;
next.base = None;
next.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
let (next, recover) = subscriber
.accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next))
.expect("the next diff extends the recovered cumulative set");
assert!(!recover);
assert_eq!(
next.transaction_hashes,
vec![transaction_a, transaction_b, transaction_c, transaction_d]
);
}
#[tokio::test]
async fn unrecoverable_indexed_gap_revokes_the_generation() {
let asserter = Asserter::new();
asserter.push_success(&100_u64);
asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64))));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock(
B256::repeat_byte(0x41),
B256::repeat_byte(0xa1),
)))
.await
.expect("index zero preview");
let BaseFlashblockWirePayload::Indexed(mut gap) =
indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3))
else {
unreachable!()
};
gap.index = 2;
gap.base = None;
gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 });
let event = subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(
BaseFlashblockWirePayload::Indexed(gap),
))
.await
.expect("preferred mode fails closed without pending recovery")
.expect("generation invalidation is observable");
assert!(matches!(event, SubscriberEvent::FlashblockInvalidated));
assert!(subscriber.latest_preconfirmation.is_none());
assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
}
#[test]
fn base_flashblock_wire_decodes_cumulative_block_shape() {
let payload: BaseFlashblockWirePayload = serde_json::from_str(
r#"{
"hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"number":"0x2ef403b",
"parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"timestamp":"0x6a68dd59",
"transactions":[]
}"#,
)
.expect("decode current Base newFlashblocks shape");
let BaseFlashblockWirePayload::Block(payload) = payload else {
panic!("expected cumulative block-shaped payload")
};
assert_eq!(payload.number, 49_233_979);
assert_eq!(payload.timestamp, 1_785_257_305);
assert_eq!(payload.hash, B256::repeat_byte(0xaa));
assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb));
assert_eq!(payload.state_root, B256::repeat_byte(0xcc));
}
#[tokio::test]
async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
let first: BaseFlashblockWirePayload = serde_json::from_str(
r#"{
"hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
"number":"0x65",
"parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
"stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
"timestamp":"0x6553f165",
"transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
}"#,
)
.expect("decode first cumulative preview");
subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(first))
.await
.expect("first preview is accepted");
let mut second_log = rpc_log(false);
second_log.block_hash = Some(B256::ZERO);
second_log.block_number = Some(102);
second_log.block_timestamp = Some(1_700_000_102);
second_log.transaction_hash = Some(B256::repeat_byte(0x42));
second_log.transaction_index = Some(0);
second_log.log_index = Some(0);
let before_preview = subscriber
.normalize_flashblock_event(SubscriberEvent::BasePendingLog {
source_id: 0,
log: second_log,
})
.await
.expect("a zero-hash log for the next block must be buffered");
assert!(before_preview.is_none());
let second: BaseFlashblockWirePayload = serde_json::from_str(
r#"{
"hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
"number":"0x66",
"parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565",
"stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222",
"timestamp":"0x6553f166",
"transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"]
}"#,
)
.expect("decode second cumulative preview");
let event = subscriber
.normalize_flashblock_event(SubscriberEvent::BaseFlashblock(second))
.await
.expect("second preview is accepted")
.expect("the matching buffered log is released");
let SubscriberEvent::PreconfirmedLogs { flashblock, logs } = event else {
panic!("expected a preconfirmed log batch")
};
assert_eq!(flashblock.block_number, 102);
assert_ne!(flashblock.content_hash, B256::ZERO);
assert_eq!(flashblock.partial_block_hash, None);
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42)));
assert_eq!(logs[0].block_hash, Some(flashblock.content_hash));
}
#[test]
fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
let sources = subscriber.pubsub_stream_sources();
assert!(
sources
.iter()
.any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling))
);
assert!(
!sources
.iter()
.any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
);
}
#[test]
#[cfg(feature = "raw-flashblocks-json")]
fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![
ReactiveInterest::Blocks(BlockInterest::default()),
log_interest_matching_rpc_log(),
];
subscriber.interests = subscriber.base_interests.clone();
let pubsub = subscriber.pubsub_stream_sources();
assert!(
pubsub
.iter()
.any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders))
);
assert!(
pubsub
.iter()
.any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. }))
);
assert!(pubsub.iter().all(|source| !matches!(
source,
SubscriberStreamSource::BaseFlashblocks
| SubscriberStreamSource::BasePendingLog { .. }
| SubscriberStreamSource::OpPendingFlashblocks
| SubscriberStreamSource::CanonicalHeadPolling
)));
assert!(
subscriber
.polling_stream_sources()
.iter()
.all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) })
);
assert!(
subscriber
.capabilities()
.supports(SubscriberCapability::Preconfirmations)
);
}
#[test]
#[cfg(feature = "raw-flashblocks-json")]
fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
.expect("configure external source");
subscriber.chain_id = Some(1);
assert!(matches!(
subscriber.validate_flashblocks_setup(),
Err(SubscriberError::InvalidConfig(message))
if message.contains("require preconfirmations")
));
}
#[tokio::test]
#[cfg(feature = "raw-flashblocks-json")]
async fn external_flashblocks_configuration_is_rejected_after_registration_starts() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut fresh = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
..SubscriberConfig::default()
},
);
fresh
.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4))
.expect("construction-time external source");
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut started = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("canonical", 3));
started.chain_id = Some(8_453);
started
.register_interests(&[log_interest_matching_rpc_log()])
.await
.expect("register canonical topology");
assert!(matches!(
started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)),
Err(SubscriberError::InvalidConfig(message))
if message.contains("before subscriber registration")
));
}
#[tokio::test]
#[cfg(feature = "raw-flashblocks-json")]
async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() {
let asserter = Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let source = ProviderRef::new("raw-json", 4);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(source.clone())
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let desired = subscriber.pubsub_stream_sources();
let mut streams = SubscriberStreams::new();
for source in desired {
streams.push(source, stream::pending().boxed());
}
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
let preflight = subscriber
.establish_flashblocks_preflight(1)
.await
.expect("external source preflight");
assert_eq!(preflight.provider(), &source);
assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates);
assert_eq!(preflight.pending_log_subscriptions(), 0);
assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
#[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream()
{
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let source = ProviderRef::new("raw-json", 4);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(source.clone())
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let filter = subscriber.log_stream_filters().remove(0);
let source_id = subscriber.log_source_id(&filter);
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PubSubLog {
id: source_id,
filter,
},
stream::pending().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
let sender = subscriber
.open_external_flashblock_update_channel(2)
.expect("bounded external queue");
let external = SubscriberStreamSource::ExternalFlashblockUpdates;
let update_stream = subscriber
.connect_source_stream(external.clone())
.await
.expect("attach receiver as subscriber source");
subscriber.install_source_stream(external, update_stream);
subscriber.sources_dirty = false;
let mut adapter = RawJsonFlashblocksAdapter::new(source);
let frame = br#"{
"payload_id":"0x1111111111111111",
"index":0,
"base":{
"parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
"block_number":"0x7",
"timestamp":"0x6553f107"
},
"diff":{
"state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"transactions":["0x01"]
},
"metadata":{
"block_number":7,
"receipts":{
"0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
"logs":[{
"address":"0x4242424242424242424242424242424242424242",
"topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
"data":"0x"
}]
}
}
}
}"#;
let update = adapter
.ingest_json(frame)
.expect("valid raw update")
.expect("snapshot update");
let valid_update = update.clone();
let sending = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(update).await })
};
let preview = subscriber
.next_scoped_batch()
.await
.expect("poll preview")
.expect("preview batch");
assert_eq!(preview.records().len(), 1);
assert!(preview.records()[0].scope().is_preconfirmed());
assert_eq!(
preview.records()[0].context.source,
InputSource::Flashblocks
);
assert!(subscriber.latest_preconfirmation.is_some());
assert_eq!(sending.await.expect("sender task"), Ok(()));
let mut invalid_update = valid_update.clone();
let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else {
unreachable!("fixture is a snapshot")
};
snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee));
let rejecting = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(invalid_update).await })
};
let rejected = subscriber
.next_scoped_batch()
.await
.expect("preferred mode keeps polling")
.expect("rejected update invalidation");
assert!(rejected.preconfirmation_invalidated());
assert!(rejected.records().is_empty());
assert!(subscriber.latest_preconfirmation.is_none());
assert_eq!(
rejecting.await.expect("sender task"),
Err(FlashblockUpdateChannelError::Rejected)
);
subscriber
.ingest_flashblock_update(valid_update)
.expect("rejected generation is ignored thereafter");
assert!(subscriber.latest_preconfirmation.is_none());
let _reset = adapter
.reset(ProviderRef::new("raw-json", 5))
.expect("advance rejected source generation");
let recovered_update = adapter
.ingest_json(frame)
.expect("valid replacement generation")
.expect("replacement snapshot update");
let recovering = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(recovered_update).await })
};
let recovered = subscriber
.next_scoped_batch()
.await
.expect("poll replacement generation")
.expect("replacement preview batch");
assert_eq!(recovered.records().len(), 1);
assert!(matches!(
&recovered.records()[0].context.chain_status,
ChainStatus::Preconfirmed { flashblock }
if flashblock.provider == ProviderRef::new("raw-json", 5)
));
assert_eq!(recovering.await.expect("sender task"), Ok(()));
drop(sender);
let invalidation = subscriber
.next_scoped_batch()
.await
.expect("poll channel closure")
.expect("closure invalidation");
assert!(invalidation.preconfirmation_invalidated());
assert!(invalidation.records().is_empty());
assert!(subscriber.latest_preconfirmation.is_none());
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams)
if streams.entries.iter().any(|entry| matches!(
entry.source,
SubscriberStreamSource::PubSubLog { id, .. } if id == source_id
))
));
}
#[tokio::test]
#[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
async fn required_external_channel_closure_fails_the_subscriber_closed() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let source = ProviderRef::new("raw-json", 4);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(source)
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
subscriber.sources_dirty = false;
let sender = subscriber
.open_external_flashblock_update_channel(1)
.expect("bounded external queue");
let external = SubscriberStreamSource::ExternalFlashblockUpdates;
let update_stream = subscriber
.connect_source_stream(external.clone())
.await
.expect("attach receiver as subscriber source");
subscriber.install_source_stream(external, update_stream);
subscriber.sources_dirty = false;
drop(sender);
assert!(matches!(
subscriber.next_scoped_batch().await,
Err(SubscriberError::Provider(ref message))
if message.contains("required external Flashblock update channel closed")
));
}
#[tokio::test]
#[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
async fn required_external_channel_rejects_a_queued_malformed_update() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let source = ProviderRef::new("raw-json", 4);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(source.clone())
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
subscriber.sources_dirty = false;
let sender = subscriber
.open_external_flashblock_update_channel(1)
.expect("bounded external queue");
let external = SubscriberStreamSource::ExternalFlashblockUpdates;
let update_stream = subscriber
.connect_source_stream(external.clone())
.await
.expect("attach receiver as subscriber source");
subscriber.install_source_stream(external, update_stream);
subscriber.sources_dirty = false;
let mut adapter = RawJsonFlashblocksAdapter::new(source);
let mut update = adapter
.ingest_json(
br#"{
"payload_id":"0x1111111111111111",
"index":0,
"base":{
"parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
"block_number":"0x7",
"timestamp":"0x6553f107"
},
"diff":{
"state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"transactions":[]
},
"metadata":{"block_number":7,"receipts":{}}
}"#,
)
.expect("valid raw frame")
.expect("snapshot update");
let FlashblockUpdate::Snapshot(snapshot) = &mut update else {
unreachable!("fixture is a snapshot")
};
snapshot.flashblock.content_hash = B256::ZERO;
let sending = tokio::spawn(async move { sender.send(update).await });
assert!(matches!(
subscriber.next_scoped_batch().await,
Err(SubscriberError::Provider(ref message))
if message.contains("content commitment is invalid")
));
assert_eq!(
sending.await.expect("sender task"),
Err(FlashblockUpdateChannelError::Rejected)
);
}
#[tokio::test]
#[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))]
async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let source = ProviderRef::new("raw-json", 4);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
max_pending_records: 1,
..SubscriberConfig::default()
},
);
subscriber
.configure_external_flashblock_updates(source.clone())
.expect("configure external source");
subscriber.chain_id = Some(1);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new());
subscriber.sources_dirty = false;
let sender = subscriber
.open_external_flashblock_update_channel(1)
.expect("bounded external queue");
let external = SubscriberStreamSource::ExternalFlashblockUpdates;
let update_stream = subscriber
.connect_source_stream(external.clone())
.await
.expect("attach receiver as subscriber source");
subscriber.install_source_stream(external, update_stream);
subscriber.sources_dirty = false;
let first_frame = br#"{
"payload_id":"0x1111111111111111",
"index":0,
"base":{
"parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606",
"block_number":"0x7",
"timestamp":"0x6553f107"
},
"diff":{
"state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"transactions":["0x01"]
},
"metadata":{
"block_number":7,
"receipts":{
"0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{
"logs":[{
"address":"0x4242424242424242424242424242424242424242",
"topics":["0x0101010101010101010101010101010101010101010101010101010101010101"],
"data":"0x"
}]
}
}
}
}"#;
let second_frame = br#"{
"payload_id":"0x1111111111111111",
"index":1,
"diff":{
"state_root":"0xabababababababababababababababababababababababababababababababab",
"block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"transactions":["0x02"]
},
"metadata":{
"block_number":7,
"receipts":{
"0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{
"logs":[
{"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"},
{"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"}
]
}
}
}
}"#;
let mut adapter = RawJsonFlashblocksAdapter::new(source);
let first = adapter
.ingest_json(first_frame)
.expect("valid first frame")
.expect("first snapshot");
let first_send = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(first).await })
};
let first_batch = subscriber
.next_scoped_batch()
.await
.expect("poll first preview")
.expect("first preview batch");
assert_eq!(first_batch.records().len(), 1);
assert_eq!(first_send.await.expect("sender task"), Ok(()));
let oversized = adapter
.ingest_json(second_frame)
.expect("valid oversized delta")
.expect("oversized standardized snapshot");
let rejected_send = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(oversized).await })
};
let invalidation = subscriber
.next_scoped_batch()
.await
.expect("poll capacity rejection")
.expect("capacity invalidation batch");
assert!(invalidation.preconfirmation_invalidated());
assert_eq!(
rejected_send.await.expect("sender task"),
Err(FlashblockUpdateChannelError::Rejected)
);
assert_eq!(subscriber.rejected_external_flashblock_generation, None);
let _ = adapter
.reset(ProviderRef::new("raw-json", 5))
.expect("advance after local capacity rejection");
let recovered = adapter
.ingest_json(first_frame)
.expect("valid recovered frame")
.expect("recovered snapshot");
let recovered_send = {
let sender = sender.clone();
tokio::spawn(async move { sender.send(recovered).await })
};
let recovered_batch = subscriber
.next_scoped_batch()
.await
.expect("poll recovered generation")
.expect("recovered preview batch");
assert_eq!(recovered_batch.records().len(), 1);
assert!(matches!(
&recovered_batch.records()[0].context.chain_status,
ChainStatus::Preconfirmed { flashblock }
if flashblock.provider == ProviderRef::new("raw-json", 5)
));
assert_eq!(recovered_send.await.expect("sender task"), Ok(()));
}
#[tokio::test]
async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() {
let asserter = Asserter::new();
let certified = rpc_block(101, B256::repeat_byte(0x65));
asserter.push_success(&Some(certified.clone()));
asserter.push_success(&Some(certified));
asserter.push_success(&Some(rpc_block(102, B256::ZERO)));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
assert!(matches!(
subscriber
.fetch_certified_canonical_head()
.await
.expect("first certified head"),
Some(SubscriberEvent::BlockHeader(_))
));
assert!(
subscriber
.fetch_certified_canonical_head()
.await
.expect("duplicate certified head")
.is_none()
);
assert!(matches!(
subscriber.fetch_certified_canonical_head().await,
Err(SubscriberError::Provider(ref message))
if message.contains("placeholder hash")
));
}
#[tokio::test]
async fn canonical_head_certification_times_out_a_silent_provider() {
let provider =
ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true));
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
canonical_head_request_timeout: Duration::from_millis(10),
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(8_453);
let result = tokio::time::timeout(
Duration::from_millis(100),
subscriber.fetch_certified_canonical_head(),
)
.await
.expect("subscriber must bound a silent provider request");
assert!(matches!(
result,
Err(SubscriberError::Provider(ref message))
if message.contains("canonical head certification timed out")
));
}
#[tokio::test]
async fn optimism_canonical_head_is_the_exact_parent_of_pending() {
let asserter = Asserter::new();
queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(10);
subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())];
let event = subscriber
.fetch_certified_canonical_head()
.await
.expect("OP pending parent can be certified")
.expect("the first certified parent is emitted");
let SubscriberEvent::BlockHeader(header) = event else {
panic!("expected a certified canonical block header")
};
assert_eq!(header.number(), 100);
assert_eq!(header.hash, B256::repeat_byte(0x64));
assert_eq!(
subscriber
.flashblocks_rpc_metrics()
.pending_block_requests(),
1
);
assert_eq!(
subscriber
.flashblocks_rpc_metrics()
.canonical_head_requests(),
1
);
assert!(asserter.read_q().is_empty());
}
#[test]
fn optimism_uses_one_bounded_pending_state_stream() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 11));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
let sources = subscriber.pubsub_stream_sources();
assert_eq!(
sources
.iter()
.filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks))
.count(),
1
);
assert!(sources.iter().all(|source| !matches!(
source,
SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. }
)));
}
#[test]
fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert_eq!(
subscriber.pending_receipt_requests_per_second_capacity(),
28
);
assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7);
subscriber
.interests
.push(ReactiveInterest::Blocks(BlockInterest::default()));
assert_eq!(
subscriber.pending_receipt_requests_per_second_capacity(),
24
);
assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6);
subscriber.interests.pop();
for _ in 0..4 {
assert!(subscriber.reserve_flashblock_rpc_methods(3));
assert_eq!(subscriber.pending_receipt_request_allowance(), 7);
assert!(subscriber.reserve_flashblock_rpc_methods(7));
}
assert!(!subscriber.reserve_flashblock_rpc_methods(1));
subscriber.reset_flashblock_tracking();
assert!(
!subscriber.reserve_flashblock_rpc_methods(1),
"a reconnect must not reset an endpoint's rolling quota window"
);
}
#[test]
fn flashblocks_config_rejects_a_zero_rpc_budget() {
let config = SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_flashblock_rpc_requests_per_second: 0,
..SubscriberConfig::default()
};
assert!(matches!(
validate_subscriber_config(&config),
Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero"
))
));
}
#[test]
fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() {
let config = SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
canonical_head_request_timeout: Duration::ZERO,
..SubscriberConfig::default()
};
assert!(matches!(
validate_subscriber_config(&config),
Err(SubscriberError::InvalidConfig(
"SubscriberConfig::canonical_head_request_timeout must be greater than zero"
))
));
}
#[tokio::test]
async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!(["flashblocksv1"]));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_flashblock_rpc_requests_per_second: 15,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let desired = subscriber.pubsub_stream_sources();
let mut streams = SubscriberStreams::new();
for source in desired {
streams.push(source, stream::pending().boxed());
}
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
assert!(matches!(
subscriber.establish_flashblocks_preflight(10).await,
Err(SubscriberError::InvalidConfig(message))
if message.contains("leaves no capacity for OP transaction receipts")
));
assert!(asserter.read_q().is_empty());
}
#[cfg(feature = "reactive-ws")]
#[tokio::test]
async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!({"flashblocks": true}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let desired = subscriber.pubsub_stream_sources();
let mut streams = SubscriberStreams::new();
for source in desired {
streams.push(source, stream::pending().boxed());
}
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
let preflight = subscriber
.establish_flashblocks_preflight(8_453)
.await
.expect("preflight succeeds");
assert_eq!(preflight.chain_id(), 8_453);
assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7));
assert_eq!(
preflight.delivery(),
FlashblocksDelivery::NativeSubscriptions
);
assert_eq!(preflight.pending_log_subscriptions(), 1);
assert_eq!(preflight.pending_log_filters(), 1);
assert_eq!(
preflight.advertised_capabilities(),
Some(&serde_json::json!({"flashblocks": true}))
);
}
#[tokio::test]
async fn optimism_preflight_probes_pending_state_without_native_subscriptions() {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!(["flashblocksv1"]));
queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!([]));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let desired = subscriber.pubsub_stream_sources();
let mut streams = SubscriberStreams::new();
for source in desired {
streams.push(source, stream::pending().boxed());
}
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
let preflight = subscriber
.establish_flashblocks_preflight(10)
.await
.expect("Optimism pending-state preflight succeeds");
assert_eq!(preflight.chain_id(), 10);
assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12));
assert_eq!(
preflight.delivery(),
FlashblocksDelivery::PendingStatePolling
);
assert_eq!(preflight.pending_log_subscriptions(), 0);
assert_eq!(preflight.pending_log_filters(), 1);
assert_eq!(
preflight.advertised_capabilities(),
Some(&serde_json::json!(["flashblocksv1"]))
);
assert!(asserter.read_q().is_empty());
}
#[test]
fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() {
let transaction_hash = B256::repeat_byte(0x7e);
let mut value = serde_json::to_value(rpc_block(101, B256::ZERO))
.expect("serialize pending block fixture");
value["transactions"] = serde_json::json!([{
"type": "0x7e",
"hash": transaction_hash,
"sourceHash": B256::repeat_byte(0x11),
"from": Address::repeat_byte(0x22),
"to": Address::repeat_byte(0x33)
}]);
let block = normalize_op_pending_block::<Ethereum>(value)
.expect("OP-specific transaction bodies are reduced to hashes");
assert_eq!(
block.transactions().as_hashes(),
Some(&[transaction_hash][..])
);
}
#[tokio::test]
async fn optimism_sampler_does_not_retry_malformed_pending_content() {
let asserter = Asserter::new();
let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO))
.expect("serialize pending block fixture");
pending["transactions"] = serde_json::json!([{"type": "0x7e"}]);
asserter.push_success(&Some(pending));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
let error = match subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
{
Err(error) => error,
Ok(_) => panic!("malformed provider content must fail immediately"),
};
assert!(
error
.to_string()
.contains("transaction is missing its hash")
);
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() {
let asserter = Asserter::new();
queue_op_pending(&asserter, rpc_block(101, B256::ZERO));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
assert!(
subscriber
.fetch_pending_flashblock(None)
.await
.expect("the exact parent certifies the pending payload")
.is_some()
);
}
#[tokio::test]
async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() {
let asserter = Asserter::new();
asserter.push_success(&Some(rpc_block(101, B256::ZERO)));
asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64))));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
assert!(matches!(
subscriber.fetch_pending_flashblock(None).await,
Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider(
ref message
))) if message.contains("does not extend its exact certified parent")
));
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() {
let asserter = Asserter::new();
let pending = rpc_block(101, B256::ZERO);
queue_op_pending(&asserter, pending.clone());
asserter.push_success(&Vec::<Log>::new());
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.fetch_pending_flashblock(None)
.await
.expect("first cumulative pending view")
.is_some()
);
assert!(
subscriber
.fetch_pending_flashblock(None)
.await
.expect("duplicate cumulative pending view")
.is_none()
);
assert_eq!(
subscriber.flashblocks_rpc_metrics(),
FlashblocksRpcMetrics {
capability_requests: 0,
provider_pair_chain_requests: 0,
canonical_head_requests: 2,
pending_block_requests: 2,
pending_log_requests: 2,
pending_receipt_requests: 0,
pending_receipts_completed: 0,
pending_receipts_unavailable: 0,
failed_requests: 0,
raced_samples: 0,
}
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() {
let asserter = Asserter::new();
let transaction = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.block_hash = Some(B256::repeat_byte(0xa2));
log.transaction_hash = Some(transaction);
log.transaction_index = Some(0);
log.log_index = Some(0);
queue_op_pending(&asserter, pending.clone());
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::Value::Null);
queue_op_pending(&asserter, pending);
asserter.push_success(&vec![log]);
asserter.push_success(&serde_json::Value::Null);
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("first pending view is coherent"),
Some(SubscriberEvent::FlashblockObserved)
));
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the unchanged view is checked again for lagging logs"),
Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
));
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() {
let asserter = Asserter::new();
let transaction = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.block_hash = Some(B256::repeat_byte(0xa2));
log.transaction_hash = Some(transaction);
log.transaction_index = Some(0);
log.log_index = Some(0);
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let event = subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("pending receipt fallback succeeds");
assert!(matches!(
event,
Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
));
assert_eq!(
subscriber
.flashblocks_rpc_metrics()
.pending_receipt_requests(),
1
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() {
let asserter = Asserter::new();
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.transaction_hash = Some(transaction_b);
log.transaction_index = Some(1);
queue_op_pending(&asserter, pending.clone());
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_a,
"logs": []
}));
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_b,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_pending_transaction_receipts_per_tick: 1,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the first bounded receipt is hydrated"),
Some(SubscriberEvent::FlashblockObserved)
));
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the remaining receipt is hydrated on the next tick"),
Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
));
assert_eq!(
subscriber
.flashblocks_rpc_metrics()
.pending_receipt_requests(),
2
);
assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() {
let asserter = Asserter::new();
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.transaction_hash = Some(transaction_b);
log.transaction_index = Some(1);
queue_op_pending(&asserter, pending.clone());
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::Value::Null);
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_b,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_pending_transaction_receipts_per_tick: 1,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the first null receipt remains retryable"),
Some(SubscriberEvent::FlashblockObserved)
));
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the next unattempted receipt is not starved"),
Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
));
assert!(
subscriber
.preconfirmed_unavailable_receipts
.contains(&transaction_a)
);
assert!(
subscriber
.preconfirmed_receipted_transactions
.contains(&transaction_b)
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() {
let asserter = Asserter::new();
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.transaction_hash = Some(transaction_b);
log.transaction_index = Some(1);
queue_op_pending(&asserter, pending.clone());
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_a,
"logs": []
}));
asserter.push_failure_msg("receipt temporarily unavailable");
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_a,
"logs": []
}));
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_b,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_pending_transaction_receipts_per_tick: 2,
max_consecutive_flashblock_poll_failures: 2,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("one failed receipt response remains retryable")
.is_none()
);
assert!(subscriber.preconfirmed_receipted_transactions.is_empty());
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the complete batch is retried transactionally"),
Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1
));
assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2);
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
assert_eq!(
subscriber
.flashblocks_rpc_metrics()
.pending_receipt_requests(),
4
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() {
let asserter = Asserter::new();
let sampled_transaction = B256::repeat_byte(0x41);
let advanced_transaction = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.transaction_hash = Some(advanced_transaction);
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({
"transactionHash": advanced_transaction,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let error = match subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
{
Err(error) => error,
Ok(_) => panic!("a receipt for another transaction must fail closed"),
};
assert!(
error
.to_string()
.contains("hash disagrees with its request")
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() {
let asserter = Asserter::new();
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
]),
);
let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
);
queue_op_pending(&asserter, first);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::Value::Null);
asserter.push_success(&serde_json::Value::Null);
queue_op_pending(&asserter, regressive.clone());
queue_op_pending(&asserter, regressive);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::Value::Null);
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("first pending view is coherent")
.is_some()
);
assert!(matches!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("regression revokes instead of terminating the stream"),
Some(SubscriberEvent::FlashblockInvalidated)
));
assert!(subscriber.latest_preconfirmation.is_none());
assert!(subscriber.pending_preconfirmation_invalidation);
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("a later coherent view establishes a fresh snapshot")
.is_some()
);
assert!(subscriber.latest_preconfirmation.is_some());
assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() {
let asserter = Asserter::new();
let first = rpc_block(101, B256::repeat_byte(0xa1));
let second = rpc_block(102, B256::repeat_byte(0xa2));
queue_op_pending(&asserter, first);
asserter.push_success(&Vec::<Log>::new());
queue_op_pending(&asserter, second);
asserter.push_success(&Vec::<Log>::new());
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("first quiet payload is observed");
assert!(!subscriber.pending_preconfirmation_invalidation);
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("replacement quiet payload is observed");
assert!(subscriber.pending_preconfirmation_invalidation);
assert_eq!(
subscriber
.latest_preconfirmation
.as_ref()
.map(|flashblock| flashblock.block_number),
Some(102)
);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_rejects_malformed_pending_receipts() {
let asserter = Asserter::new();
let transaction = B256::repeat_byte(0x42);
let pending = rpc_block(101, B256::ZERO).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]),
);
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&serde_json::json!({"transactionHash": transaction}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let error = match subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
{
Err(error) => error,
Ok(_) => panic!("malformed receipt content must fail closed"),
};
assert!(error.to_string().contains("missing its log array"));
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() {
let asserter = Asserter::new();
let transaction_a = B256::repeat_byte(0x41);
let transaction_b = B256::repeat_byte(0x42);
let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]),
);
let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions(
alloy_network::primitives::BlockTransactions::Hashes(vec![
transaction_a,
transaction_b,
]),
);
let mut log = rpc_log(false);
log.block_number = Some(101);
log.block_hash = Some(B256::repeat_byte(0xa2));
log.transaction_hash = Some(transaction_b);
log.transaction_index = Some(1);
log.log_index = Some(0);
queue_op_pending(&asserter, first);
asserter.push_success(&vec![log.clone()]);
asserter.push_success(&serde_json::Value::Null);
queue_op_pending(&asserter, second);
asserter.push_success(&vec![log.clone()]);
asserter.push_success(&serde_json::Value::Null);
asserter.push_success(&serde_json::json!({
"transactionHash": transaction_b,
"logs": [log]
}));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("a cross-request race remains retryable")
.is_none()
);
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the next coherent cumulative view is delivered")
.is_some()
);
assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1);
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_uses_the_paired_pending_state_provider() {
let stream_asserter = Asserter::new();
let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
let state_asserter = Asserter::new();
queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO));
state_asserter.push_success(&Vec::<Log>::new());
let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
stream_provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12))
.with_flashblocks_state_provider(state_provider);
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("paired pending-state reads succeed")
.is_some()
);
assert!(state_asserter.read_q().is_empty());
assert!(stream_asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_retries_an_isolated_provider_request_failure() {
let asserter = Asserter::new();
let pending = rpc_block(101, B256::ZERO);
queue_op_pending(&asserter, pending.clone());
asserter.push_failure_msg("temporarily unavailable");
queue_op_pending(&asserter, pending);
asserter.push_success(&Vec::<Log>::new());
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_consecutive_flashblock_poll_failures: 2,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("one request failure stays retryable")
.is_none()
);
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the next cumulative view retries the missing logs")
.is_some()
);
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn optimism_sampler_surfaces_sustained_provider_request_failures() {
let asserter = Asserter::new();
asserter.push_failure_msg("temporarily unavailable");
asserter.push_failure_msg("still unavailable");
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
max_consecutive_flashblock_poll_failures: 2,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12));
subscriber.chain_id = Some(10);
assert!(
subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
.expect("the first request failure stays retryable")
.is_none()
);
let error = match subscriber
.normalize_flashblock_event(SubscriberEvent::OpFlashblockTick)
.await
{
Err(error) => error,
Ok(_) => panic!("the configured consecutive-failure limit must fail closed"),
};
assert!(error.to_string().contains("still unavailable"));
assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2);
assert!(asserter.read_q().is_empty());
}
#[tokio::test]
async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("wrong-chain", 1));
subscriber.chain_id = Some(10);
subscriber.interests = vec![log_interest_matching_rpc_log()];
assert!(matches!(
subscriber.establish_flashblocks_preflight(8_453).await,
Err(SubscriberError::ChainMismatch {
expected: 8_453,
actual: 10
})
));
}
#[tokio::test]
async fn optimism_preflight_rejects_a_mismatched_paired_provider() {
let stream_asserter = Asserter::new();
let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
let state_asserter = Asserter::new();
state_asserter.push_success(&serde_json::json!(["flashblocksv1"]));
state_asserter.push_success(&8_453_u64);
let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
stream_provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("op-paid", 12))
.with_flashblocks_state_provider(state_provider);
subscriber.chain_id = Some(10);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
let desired = subscriber.pubsub_stream_sources();
let mut streams = SubscriberStreams::new();
for source in desired {
streams.push(source, stream::pending().boxed());
}
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
assert!(matches!(
subscriber.establish_flashblocks_preflight(10).await,
Err(SubscriberError::ChainMismatch {
expected: 10,
actual: 8_453
})
));
assert!(state_asserter.read_q().is_empty());
assert!(stream_asserter.read_q().is_empty());
}
#[test]
fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() {
let parent = BlockRef {
number: 79,
hash: B256::repeat_byte(0x79),
parent_hash: Some(B256::repeat_byte(0x78)),
timestamp: Some(1_700_000_079),
};
let old_tip = BlockRef {
number: 80,
hash: B256::repeat_byte(0x80),
parent_hash: Some(parent.hash),
timestamp: Some(1_700_000_080),
};
let replacement = BlockRef {
hash: B256::repeat_byte(0xe0),
parent_hash: Some(B256::repeat_byte(0xdf)),
..old_tip
};
let mut state =
CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None);
let rewind = apply_sequence_canonical_block(&mut state, &replacement, false)
.expect("replacement metadata is structurally valid")
.expect("unknown parent is an observable rewind");
assert_eq!(rewind.common_ancestor, None);
assert_eq!(rewind.dropped, vec![parent, old_tip]);
assert_eq!(state.retained_canonical_history(), &[replacement]);
assert_eq!(state.coverage_head(), Some(&replacement));
assert_eq!(state.safe_head(), None);
assert_eq!(state.finalized_head(), None);
}
#[test]
fn handler_ids_are_non_empty_across_construction_and_deserialization() {
assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError);
let valid = HandlerId::try_new("owner-1").expect("non-empty id");
let encoded = serde_json::to_string(&valid).expect("serialize id");
assert_eq!(
serde_json::from_str::<HandlerId>(&encoded).expect("deserialize valid id"),
valid
);
assert!(serde_json::from_str::<HandlerId>(r#"""#).is_err());
}
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,
}
}
fn rpc_transaction(chain_id: Option<u64>) -> alloy_rpc_types_eth::Transaction {
use alloy_consensus::SignableTransaction as _;
let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy {
chain_id,
..Default::default()
}
.into_signed(alloy_primitives::Signature::test_signature())
.into();
alloy_rpc_types_eth::Transaction {
inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO),
block_hash: None,
block_number: None,
transaction_index: None,
effective_gas_price: None,
}
}
#[cfg(feature = "reactive-ws")]
fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> 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(block_number as u8)),
block_number: Some(block_number),
block_timestamp: Some(1_700_000_000 + block_number),
transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)),
transaction_index: Some(transaction_index),
log_index: Some(log_index),
removed: false,
}
}
#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))]
fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block {
alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header {
hash,
inner: alloy_consensus::Header {
number,
parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8),
timestamp: 1_700_000_000 + number,
..Default::default()
},
total_difficulty: None,
size: None,
})
}
fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) {
let parent = rpc_block(
pending.header().number().saturating_sub(1),
pending.header().parent_hash(),
);
asserter.push_success(&Some(pending));
asserter.push_success(&Some(parent));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn verified_log_context_fetches_and_caches_exact_parent_identity() {
let stream_asserter = Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone());
let verification_asserter = Asserter::new();
verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7))));
let verification_provider =
ProviderBuilder::new().connect_mocked_client(verification_asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
verify_log_block_context: true,
..SubscriberConfig::default()
},
)
.with_log_verification_provider(verification_provider);
let log = rpc_log_at(7, 0, 0);
subscriber
.verify_log_block_context(&log)
.await
.expect("verify live log block");
subscriber
.verify_log_block_context(&log)
.await
.expect("reuse verified block cache");
let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription));
assert_eq!(
record.context.block.expect("verified block").parent_hash,
Some(B256::repeat_byte(6))
);
assert!(
verification_asserter.read_q().is_empty(),
"one provider lookup should verify every log in the same block"
);
assert!(
stream_asserter.read_q().is_empty(),
"verification must not use the high-volume stream provider"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn stream_with_termination_yields_terminal_source_marker() {
let mut stream = stream_with_termination::<Ethereum, _>(
stream::iter([SubscriberEvent::<Ethereum>::PendingHash(B256::repeat_byte(
0xaa,
))]),
SubscriberStreamSource::PubSubPendingHashes,
);
assert!(matches!(
stream.next().await,
Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa)
));
assert!(matches!(
stream.next().await,
Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub()
));
assert!(stream.next().await.is_none());
}
#[test]
fn reconnect_delay_doubles_until_capped() {
assert_eq!(
next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)),
Duration::from_millis(500)
);
assert_eq!(
next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)),
Duration::from_secs(1)
);
assert_eq!(
next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)),
Duration::ZERO
);
}
#[test]
fn canonical_logs_are_deduped_but_removed_logs_are_not() {
let included = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
let removed = log_input_record::<Ethereum>(rpc_log(true), InputSource::Subscription);
assert!(should_dedupe_record(&included));
assert!(!should_dedupe_record(&removed));
}
#[test]
fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() {
let set_context_timestamp = |record: &mut ReactiveInputRecord<Ethereum>,
timestamp: Option<u64>| {
record.context.block.as_mut().expect("block").timestamp = timestamp;
match &mut record.context.chain_status {
ChainStatus::Included { block, .. }
| ChainStatus::Safe { block }
| ChainStatus::Finalized { block }
| ChainStatus::Reorged {
dropped_from: block,
} => block.timestamp = timestamp,
ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => {
panic!("log record is canonical")
}
}
};
let mut payload_only = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
let payload_timestamp = match &payload_only.input {
ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"),
_ => unreachable!(),
};
set_context_timestamp(&mut payload_only, None);
let mut context_only = payload_only.clone();
if let ReactiveInput::Log(log) = &mut context_only.input {
log.block_timestamp = None;
}
set_context_timestamp(&mut context_only, Some(payload_timestamp + 1));
assert!(matches!(
dedupe_records(vec![payload_only, context_only]),
Err(ReactiveError::InvalidInputRecord { .. })
));
let mut partial = log_input_record::<Ethereum>(rpc_log(false), InputSource::Backfill);
if let ReactiveInput::Log(log) = &mut partial.input {
log.block_timestamp = None;
}
set_context_timestamp(&mut partial, None);
let complete = log_input_record::<Ethereum>(rpc_log(false), InputSource::Subscription);
let deduped =
dedupe_records(vec![partial, complete]).expect("compatible metadata enriches");
assert_eq!(deduped.len(), 1);
deduped[0]
.validated_identity()
.expect("merged record remains coherent");
let resolved = resolve_record_block_payload_metadata(
&deduped[0],
*canonical_record_block(&deduped[0]).expect("canonical"),
)
.expect("effective block");
assert_eq!(resolved.timestamp, Some(payload_timestamp));
}
#[test]
fn full_block_bodies_are_never_suppressed_from_header_hash_alone() {
use alloy_rpc_types_eth::{Block, Header};
let block_ref = BlockRef {
number: 7,
hash: B256::repeat_byte(0x77),
parent_hash: Some(B256::repeat_byte(0x66)),
timestamp: Some(1_700_000_007),
};
let block = Block::empty(Header {
hash: block_ref.hash,
inner: alloy_consensus::Header {
number: block_ref.number,
parent_hash: block_ref.parent_hash.expect("parent"),
timestamp: block_ref.timestamp.expect("timestamp"),
..Default::default()
},
total_difficulty: None,
size: None,
});
let record = ReactiveInputRecord::<Ethereum>::new(
ReactiveInput::FullBlock(block),
ReactiveContext {
chain_id: Some(1),
source: InputSource::Subscription,
chain_status: ChainStatus::Included {
block: block_ref,
confirmations: 0,
},
block: Some(block_ref),
transaction_index: None,
log_index: None,
},
);
assert!(!record.is_payload_deduplicable());
assert!(!record.same_deduplicable_payload(&record));
let retained = dedupe_scoped_records(vec![
(
record.clone(),
DeliveryAudience::All,
DeliveryScope::Canonical,
),
(record, DeliveryAudience::All, DeliveryScope::Canonical),
])
.expect("non-deduplicable bodies are preserved, not treated as conflicts");
assert_eq!(retained.len(), 2);
}
#[test]
fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() {
let pending_context = ReactiveContext {
chain_id: Some(1),
source: InputSource::Batch,
chain_status: ChainStatus::Pending,
block: None,
transaction_index: None,
log_index: None,
};
let mut included_pending = rpc_transaction(Some(1));
included_pending.block_hash = Some(B256::repeat_byte(0xaa));
assert!(matches!(
ReactiveInputRecord::<Ethereum>::new(
ReactiveInput::PendingTx(included_pending),
pending_context.clone(),
)
.validated_identity(),
Err(ReactiveError::InvalidInputRecord { .. })
));
assert!(matches!(
ReactiveInputRecord::<Ethereum>::new(
ReactiveInput::PendingTx(rpc_transaction(Some(2))),
pending_context,
)
.validated_identity(),
Err(ReactiveError::InvalidInputRecord { .. })
));
let block_ref = BlockRef {
number: 8,
hash: B256::repeat_byte(0x88),
parent_hash: Some(B256::repeat_byte(0x77)),
timestamp: Some(1_700_000_008),
};
let header = alloy_rpc_types_eth::Header {
hash: block_ref.hash,
inner: alloy_consensus::Header {
number: block_ref.number,
parent_hash: block_ref.parent_hash.expect("parent"),
timestamp: block_ref.timestamp.expect("timestamp"),
..Default::default()
},
total_difficulty: None,
size: None,
};
let context = ReactiveContext {
chain_id: Some(1),
source: InputSource::Batch,
chain_status: ChainStatus::Included {
block: block_ref,
confirmations: 0,
},
block: Some(block_ref),
transaction_index: None,
log_index: None,
};
for transaction in [
alloy_rpc_types_eth::Transaction {
block_hash: Some(B256::repeat_byte(0xff)),
..rpc_transaction(Some(1))
},
alloy_rpc_types_eth::Transaction {
block_hash: Some(block_ref.hash),
block_number: Some(block_ref.number),
transaction_index: Some(1),
..rpc_transaction(Some(1))
},
rpc_transaction(Some(2)),
] {
let block = alloy_rpc_types_eth::Block::new(
header.clone(),
alloy_network::primitives::BlockTransactions::Full(vec![transaction]),
);
assert!(matches!(
ReactiveInputRecord::<Ethereum>::new(
ReactiveInput::FullBlock(block),
context.clone(),
)
.validated_identity(),
Err(ReactiveError::InvalidInputRecord { .. })
));
}
}
#[test]
fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig::default(),
);
let owner = HandlerId::new("compat-owner");
subscriber
.add_interest_owner(
owner.clone(),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})],
)
.unwrap();
let log = rpc_log(false);
subscriber.enqueue_compat_owner_record(
log_input_record(log.clone(), InputSource::Backfill),
owner.clone(),
);
subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
let batch = subscriber
.drain_next_scoped_batch()
.expect("owner catch-up and residual live copies");
assert_eq!(batch.records.len(), 2);
assert_eq!(
batch.records[0].scope,
SubscriberInputScope::OwnerOnlyHandlers {
owners: vec![owner.clone()]
}
);
assert_eq!(
batch.records[1].scope,
SubscriberInputScope::CanonicalResidual {
owners: Vec::new(),
excluded: vec![owner.clone()]
}
);
let reactive = batch.into_reactive_batch();
assert_eq!(
reactive.record_audience(0),
Some(&DeliveryAudience::Owners(vec![owner.clone()]))
);
assert_eq!(
reactive.record_delivery_scope(0),
Some(DeliveryScope::OwnerCatchup)
);
assert_eq!(
reactive.record_audience(1),
Some(&DeliveryAudience::AllExcept(vec![owner]))
);
assert_eq!(
reactive.record_delivery_scope(1),
Some(DeliveryScope::Canonical)
);
}
#[test]
fn active_owner_replacement_commits_atomically_to_one_new_epoch() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig::default(),
);
let owner = HandlerId::new("replace-owner");
let original = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x41)),
local_matcher: None,
route_key: None,
});
let replacement_interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let active = subscriber
.stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live)
.unwrap();
assert!(subscriber.activate_interest_owner(&active));
let replacement = subscriber
.stage_interest_owner_replacement(
owner,
&[replacement_interest],
SubscriberOwnerStart::Live,
)
.unwrap();
assert!(subscriber.commit_interest_owner_replacement(&active, &replacement));
assert_eq!(subscriber.interest_owner_state(&active), None);
assert_eq!(
subscriber.interest_owner_state(&replacement),
Some(SubscriberOwnerState::Active)
);
assert_eq!(subscriber.registered_interests().len(), 1);
}
#[test]
fn compatibility_and_epoch_owner_lifecycles_cannot_mix() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig::default(),
);
let owner = HandlerId::new("one-lifecycle");
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
owner.clone(),
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
)
.expect("stage epoch owner");
assert!(matches!(
subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)),
Err(SubscriberError::InvalidConfig(_))
));
assert_eq!(
subscriber.interest_owner_state(&epoch),
Some(SubscriberOwnerState::Staged)
);
assert!(subscriber.abort_interest_owner(&epoch));
subscriber
.add_interest_owner(owner.clone(), std::slice::from_ref(&interest))
.expect("compatibility owner after epoch abort");
assert!(matches!(
subscriber.stage_interest_owner_replacement(
owner,
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
),
Err(SubscriberOwnerError::AlreadyRegistered(_))
));
}
#[test]
fn pending_record_overflow_is_sticky_and_fail_closed() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig {
max_pending_records: 1,
..SubscriberConfig::default()
},
);
subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})];
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
let mut second = rpc_log(false);
second.log_index = Some(6);
second.transaction_hash = Some(B256::repeat_byte(0x04));
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: second,
});
assert_eq!(subscriber.pending_records.len(), 1);
assert!(matches!(
subscriber.check_resource_error(),
Err(SubscriberError::ResourceExhausted(_))
));
subscriber.reset_delivery_state();
assert!(subscriber.check_resource_error().is_ok());
}
#[test]
fn historical_log_payload_bytes_are_bounded_independently_of_log_count() {
let baseline = rpc_log(false);
let fixed_bytes =
validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX)
.expect("measure fixed log accounting");
let mut large = baseline;
large.inner = alloy_primitives::Log::new_unchecked(
Address::repeat_byte(0x42),
vec![B256::repeat_byte(0x01)],
Bytes::from(vec![0u8; 256]),
);
assert!(matches!(
validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255),
Err(SubscriberError::ResourceExhausted(_))
));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() {
use alloy_rpc_types_eth::{Block, Header};
let asserter = Asserter::new();
let baseline = BlockRef {
number: 6,
hash: B256::repeat_byte(6),
parent_hash: Some(B256::repeat_byte(5)),
timestamp: Some(1_700_000_006),
};
let through = BlockRef {
number: 7,
hash: B256::repeat_byte(7),
parent_hash: Some(baseline.hash),
timestamp: Some(1_700_000_007),
};
let rpc_block = || -> Block {
Block::empty(Header {
hash: through.hash,
inner: alloy_consensus::Header {
number: through.number,
parent_hash: through.parent_hash.expect("parent"),
timestamp: through.timestamp.expect("timestamp"),
..Default::default()
},
total_difficulty: None,
size: None,
})
};
let mut historical = rpc_log(false);
historical.block_hash = Some(through.hash);
historical.block_timestamp = through.timestamp;
asserter.push_success(&Some(rpc_block()));
asserter.push_success(&vec![historical]);
asserter.push_success(&Some(rpc_block()));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig {
max_pending_records: 1,
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(1);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("capacity-owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(baseline),
)
.expect("stage owner");
subscriber.sources_dirty = false;
subscriber.state = AlloySubscriberState::Empty;
subscriber.push_pending_record(SubscriberInputRecord {
record: log_input_record(rpc_log(false), InputSource::Poll),
scope: SubscriberInputScope::Canonical { owners: Vec::new() },
});
let error = subscriber
.reconcile_interest_owner(&epoch, through)
.await
.expect_err("historical delivery cannot displace the queued live record");
assert!(matches!(
error,
SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_))
));
assert!(subscriber.interest_owner_progress(&epoch).is_none());
assert_eq!(subscriber.pending_records.len(), 1);
assert!(matches!(
subscriber.pending_records[0].scope,
SubscriberInputScope::Canonical { .. }
));
}
#[test]
fn lazy_backfill_queue_capacity_failure_is_atomic() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig {
max_pending_backfills: 1,
..SubscriberConfig::default()
},
);
let interest = |address| {
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(address),
local_matcher: None,
route_key: None,
})
};
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("owner-a"),
&[interest(Address::repeat_byte(0x41))],
SubscriberBackfill::from_block(10),
)
.expect("first queued backfill");
let error = subscriber
.add_interest_owner_with_backfill(
HandlerId::new("owner-b"),
&[interest(Address::repeat_byte(0x42))],
SubscriberBackfill::from_block(10),
)
.expect_err("second backfill must exceed capacity");
assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
assert!(
subscriber
.owner_interests(&HandlerId::new("owner-b"))
.is_none()
);
assert_eq!(subscriber.pending_backfills.len(), 1);
}
#[test]
fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig {
max_pending_backfills: 1,
..SubscriberConfig::default()
},
);
let interest = |address| {
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(address),
local_matcher: None,
route_key: None,
})
};
subscriber
.add_interest_owner(
HandlerId::new("crash-stale"),
&[interest(Address::repeat_byte(0xee))],
)
.expect("seed stale owner");
subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))];
subscriber.rebuild_registered_interests();
subscriber.push_pending_record(SubscriberInputRecord {
record: log_input_record(rpc_log(false), InputSource::Poll),
scope: SubscriberInputScope::Canonical { owners: Vec::new() },
});
let baseline = BlockRef {
number: 100,
hash: B256::repeat_byte(100),
parent_hash: Some(B256::repeat_byte(99)),
timestamp: Some(1_700_000_100),
};
let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
let error = subscriber
.replace_interest_owners_with_global_backfill(
vec![
(
HandlerId::new("pool-a"),
vec![interest(Address::repeat_byte(0xa1))],
),
(
HandlerId::new("pool-b"),
vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0xb2))
.from_block(7),
local_matcher: None,
route_key: None,
})],
),
],
backfill,
)
.expect_err("two backfills exceed atomic capacity");
assert!(matches!(error, SubscriberError::ResourceExhausted(_)));
assert!(
subscriber
.owner_interests(&HandlerId::new("crash-stale"))
.is_some(),
"failed replacement must preserve the prior topology"
);
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-a"))
.is_none()
);
assert_eq!(subscriber.base_interests.len(), 1);
assert_eq!(subscriber.pending_records.len(), 1);
subscriber
.replace_interest_owners_with_global_backfill(
vec![(
HandlerId::new("pool-a"),
vec![interest(Address::repeat_byte(0xa1))],
)],
backfill,
)
.expect("replacement within capacity");
assert!(
subscriber
.owner_interests(&HandlerId::new("crash-stale"))
.is_none(),
"successful exact replacement removes stale owners"
);
assert!(
subscriber.base_interests.is_empty(),
"successful exact replacement removes stale unowned interests"
);
assert!(
subscriber.drain_next_scoped_batch().is_none(),
"stale canonical delivery must not escape before C + 1 recovery"
);
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-a"))
.is_some()
);
assert_eq!(subscriber.pending_backfills.len(), 1);
assert_eq!(subscriber.pending_backfills[0].backfill, backfill);
assert!(
subscriber.pending_backfills[0].owner.is_none(),
"startup history must be global canonical catch-up, not owner-only"
);
}
#[test]
fn exclusive_canonical_backfill_rejects_block_number_overflow() {
let baseline = BlockRef {
number: u64::MAX,
hash: B256::repeat_byte(0xff),
parent_hash: None,
timestamp: None,
};
assert!(matches!(
SubscriberBackfill::after_canonical_block(baseline),
Err(SubscriberError::InvalidConfig(_))
));
}
#[tokio::test(flavor = "multi_thread")]
async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() {
let asserter = Asserter::new();
asserter.push_success(&101u64);
asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101))));
asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee))));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig::default(),
);
let baseline = BlockRef {
number: 100,
hash: B256::repeat_byte(0xaa),
parent_hash: None,
timestamp: None,
};
let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1");
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
local_matcher: None,
route_key: None,
})],
backfill,
)
.expect("queue post-baseline backfill");
let error = subscriber
.drain_pending_backfills()
.await
.expect_err("provider branch differs at retained baseline");
assert!(matches!(error, SubscriberError::InvalidBackfill(_)));
assert_eq!(subscriber.pending_backfills.len(), 1);
assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101);
assert!(subscriber.pending_records.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery()
{
let asserter = Asserter::new();
let retained = BlockRef {
number: 10,
hash: B256::repeat_byte(10),
parent_hash: Some(B256::repeat_byte(9)),
timestamp: Some(1_700_000_010),
};
let activation = BlockRef {
number: 12,
hash: B256::repeat_byte(12),
parent_hash: Some(B256::repeat_byte(11)),
timestamp: Some(1_700_000_012),
};
asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
asserter.push_success(&vec![rpc_log_at(10, 2, 2)]);
asserter.push_success(&vec![rpc_log_at(10, 1, 1)]);
asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
asserter.push_success(&activation.number);
asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
asserter.push_success(&Some(rpc_block(retained.number, retained.hash)));
asserter.push_success(&vec![rpc_log_at(12, 2, 2)]);
asserter.push_success(&vec![rpc_log_at(11, 1, 1)]);
asserter.push_success(&Some(rpc_block(activation.number, activation.hash)));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Auto,
SubscriberConfig::default(),
);
let interests = (0..257)
.map(|start| {
ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01))
.from_block(start),
local_matcher: None,
route_key: None,
})
})
.collect::<Vec<_>>();
subscriber
.add_interest_owner_with_canonical_catchup(
HandlerId::new("many-filters"),
&interests,
retained,
)
.expect("queue coordinated windows");
assert_eq!(subscriber.pending_backfills.len(), 2);
assert_eq!(subscriber.pending_backfills[0].filters.len(), 257);
assert_eq!(subscriber.pending_backfills[1].filters.len(), 257);
subscriber
.drain_pending_backfills()
.await
.expect("owner filter group");
let owner = subscriber
.drain_next_scoped_batch()
.expect("owner ordered batch");
assert_eq!(owner.records.len(), 2);
assert_eq!(owner.records[0].record.context.transaction_index, Some(1));
assert_eq!(owner.records[1].record.context.transaction_index, Some(2));
assert!(
owner.records.iter().all(|record| matches!(
record.scope,
SubscriberInputScope::OwnerOnlyHandlers { .. }
))
);
subscriber
.drain_pending_backfills()
.await
.expect("global filter group");
let global = subscriber
.drain_next_scoped_batch()
.expect("global ordered batch");
assert_eq!(global.records.len(), 2);
assert_eq!(
global.records[0].record.context.block.map(|b| b.number),
Some(11)
);
assert_eq!(
global.records[1].record.context.block.map(|b| b.number),
Some(12)
);
assert!(
global
.records
.iter()
.all(|record| record.scope.is_canonical())
);
assert!(matches!(
global.chain_controls.as_slice(),
[ChainControl::Barrier {
block: Some(block),
..
}] if block == &activation
));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn aborting_staged_epoch_purges_only_its_buffered_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.chain_id = Some(1);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let owner_a = subscriber
.stage_interest_owner(
HandlerId::new("owner-a"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
)
.unwrap();
let owner_b = subscriber
.stage_interest_owner(
HandlerId::new("owner-b"),
&[interest],
SubscriberOwnerStart::Live,
)
.unwrap();
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
assert!(subscriber.abort_interest_owner(&owner_a));
let batch = subscriber
.next_scoped_batch()
.await
.unwrap()
.expect("shared canonical delivery remains queued");
assert_eq!(batch.records.len(), 1);
assert_eq!(
batch.records[0].scope,
SubscriberInputScope::Canonical {
owners: vec![owner_b]
}
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn owner_backfill_dedupe_never_suppresses_canonical_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.chain_id = Some(1);
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
})],
SubscriberOwnerStart::Live,
)
.unwrap();
let log = rpc_log(false);
subscriber.enqueue_owner_record(
log_input_record(log.clone(), InputSource::Backfill),
epoch.clone(),
);
subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log });
let batch = subscriber
.next_scoped_batch()
.await
.unwrap()
.expect("owner backfill and canonical live delivery");
assert_eq!(batch.records.len(), 2);
assert_eq!(
batch.records[0].scope,
SubscriberInputScope::OwnerOnly {
owners: vec![epoch]
}
);
assert_eq!(
batch.records[1].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"owner replay dedupe must not suppress the global live record"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn reconcile_fetch_drains_live_burst_beyond_output_batch_capacity() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig {
max_batch_size: 2,
..SubscriberConfig::default()
},
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::Live,
)
.unwrap();
subscriber.sources_dirty = false;
let mut duplicate = rpc_log(false);
duplicate.transaction_hash = Some(B256::repeat_byte(1));
duplicate.log_index = Some(0);
let events = (0u8..10).map(|index| {
let mut log = rpc_log(false);
log.transaction_hash = Some(B256::repeat_byte(index.saturating_add(1)));
log.log_index = Some(index as u64);
SubscriberEvent::Log { source_id: 0, log }
});
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PollingLog { filter },
stream::iter(events).boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let mut polls = 0usize;
let fetched_duplicate = duplicate.clone();
let fetch = poll_fn(move |cx| {
polls += 1;
if polls > 10 {
std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>(fetched_duplicate.clone()))
} else {
cx.waker().wake_by_ref();
std::task::Poll::Pending
}
});
let target_epochs = HashSet::from([epoch.clone()]);
let fetched_duplicate = subscriber
.drive_reconcile_fetch(fetch, &target_epochs)
.await
.unwrap();
subscriber.enqueue_owner_record_for_owners_unmerged(
log_input_record(fetched_duplicate, InputSource::Backfill),
vec![epoch.clone()],
);
subscriber.promote_reconcile_owner_records(&target_epochs);
assert_eq!(subscriber.pending_records.len(), 20);
assert!(subscriber.pending_records.iter().take(10).all(|record| {
record.scope == SubscriberInputScope::Canonical { owners: Vec::new() }
}));
assert!(subscriber.pending_records.iter().skip(10).all(|record| {
record.scope
== SubscriberInputScope::OwnerOnly {
owners: vec![epoch.clone()],
}
}));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn reconcile_fetch_waits_for_provider_when_live_topology_is_empty() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig::default(),
);
subscriber.chain_id = Some(1);
subscriber.sources_dirty = false;
let mut first_poll = true;
let fetch = poll_fn(move |cx| {
if first_poll {
first_poll = false;
cx.waker().wake_by_ref();
std::task::Poll::Pending
} else {
std::task::Poll::Ready(Ok::<_, SubscriberOwnerError>("certified"))
}
});
let result = subscriber
.drive_reconcile_fetch(fetch, &HashSet::new())
.await
.expect("an empty live topology must not be mistaken for termination");
assert_eq!(result, "certified");
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(all(feature = "reactive-polling", feature = "reactive-ws"))]
async fn successful_owner_reconcile_seeds_its_live_filter_reconnect_anchor() {
use alloy_rpc_types_eth::{Block, Header};
let asserter = Asserter::new();
let baseline = BlockRef {
number: 100,
hash: B256::repeat_byte(0x64),
parent_hash: Some(B256::repeat_byte(0x63)),
timestamp: Some(1_700_000_100),
};
let through = BlockRef {
number: 101,
hash: B256::repeat_byte(0x65),
parent_hash: Some(baseline.hash),
timestamp: Some(1_700_000_101),
};
let rpc_block = || -> Block {
Block::empty(Header {
hash: through.hash,
inner: alloy_consensus::Header {
number: through.number,
parent_hash: through.parent_hash.unwrap(),
timestamp: through.timestamp.unwrap(),
..Default::default()
},
total_difficulty: None,
size: None,
})
};
asserter.push_success(&Some(rpc_block()));
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Some(rpc_block()));
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.chain_id = Some(1);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xac)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("reconnect-anchor"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(baseline),
)
.unwrap();
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let source = SubscriberStreamSource::PubSubLog {
id: subscriber.log_source_id(&filter),
filter: filter.clone(),
};
let mut streams = SubscriberStreams::new();
streams.push(source, stream::pending().boxed());
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = false;
subscriber
.reconcile_interest_owner(&epoch, through)
.await
.unwrap();
assert_eq!(subscriber.log_anchor(&filter), Some(through.number));
assert!(asserter.read_q().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn cancelled_reconcile_retains_hidden_owner_live_delivery_for_retry() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig::default(),
);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(BlockRef {
number: 100,
hash: B256::repeat_byte(0x64),
parent_hash: None,
timestamp: None,
}),
)
.unwrap();
subscriber.sources_dirty = false;
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let event = SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
};
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::PollingLog { filter },
stream::once(async move { event })
.chain(stream::pending())
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let targets = HashSet::from([epoch.clone()]);
{
let fetch = futures::future::pending::<Result<(), SubscriberOwnerError>>();
let drive = subscriber.drive_reconcile_fetch(fetch, &targets);
futures::pin_mut!(drive);
poll_fn(|cx| {
assert!(drive.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
}
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(
subscriber.pending_records[0].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"canonical delivery commits immediately at a cancellation-safe boundary"
);
assert_eq!(subscriber.pending_reconcile_owner_records.len(), 1);
subscriber
.drive_reconcile_fetch(futures::future::ready(Ok(())), &targets)
.await
.unwrap();
subscriber.promote_reconcile_owner_records(&targets);
assert!(subscriber.pending_reconcile_owner_records.is_empty());
assert_eq!(subscriber.pending_records.len(), 2);
assert_eq!(
subscriber.pending_records[0].scope,
SubscriberInputScope::Canonical { owners: Vec::new() },
"canonical delivery remains target-excluded"
);
assert_eq!(
subscriber.pending_records[1].scope,
SubscriberInputScope::OwnerOnly {
owners: vec![epoch]
},
"retry commit appends hidden owner delivery after historical catch-up"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn control_cancellation_preserves_terminated_source_reconcile_intent() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
reconnect: SubscriberReconnectConfig {
initial_delay: Duration::from_secs(60),
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(1);
let interest = ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0x42)),
local_matcher: None,
route_key: None,
});
let epoch = subscriber
.stage_interest_owner(
HandlerId::new("owner"),
std::slice::from_ref(&interest),
SubscriberOwnerStart::PostBlock(BlockRef {
number: 7,
hash: B256::repeat_byte(0x07),
parent_hash: Some(B256::repeat_byte(0x06)),
timestamp: Some(1_700_000_007),
}),
)
.unwrap();
subscriber.sources_dirty = false;
subscriber.stream_revision = 1;
let entry = subscriber
.owned_interests
.iter_mut()
.find(|entry| entry.epoch.as_ref() == Some(&epoch))
.unwrap();
entry.progress = Some(SubscriberOwnerProgress {
owner: epoch.clone(),
through: entry.baseline.unwrap(),
});
entry.progress_stream_revision = Some(1);
let filter = log_filters(std::slice::from_ref(&interest)).pop().unwrap();
let source = SubscriberStreamSource::PubSubLog {
id: subscriber.log_source_id(&filter),
filter,
};
let mut streams = SubscriberStreams::new();
streams.push(
source.clone(),
stream::iter([SubscriberEvent::StreamTerminated(source)]).boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let prior_revision = subscriber.stream_revision;
let mut first_poll = true;
let control = poll_fn(move |cx| {
if first_poll {
first_poll = false;
cx.waker().wake_by_ref();
std::task::Poll::Pending
} else {
std::task::Poll::Ready("stop")
}
});
futures::pin_mut!(control);
let outcome = subscriber
.next_scoped_batch_or(control.as_mut())
.await
.unwrap();
assert!(matches!(outcome, SubscriberDriverPoll::Control("stop")));
assert!(subscriber.sources_dirty);
assert!(subscriber.stream_revision > prior_revision);
assert!(
!subscriber.activate_interest_owner(&epoch),
"progress certified against the terminated stream revision is stale"
);
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async 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.chain_id = Some(1);
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()),
])
.await
.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.chain_id = Some(1);
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:?}"
);
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async fn flashblock_stream_termination_invalidates_before_reconnect_io() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 7));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![log_interest_matching_rpc_log()];
subscriber.interests = subscriber.base_interests.clone();
subscriber.sources_dirty = false;
let preview: BaseFlashblockWirePayload = serde_json::from_str(
r#"{
"hash":"0x0000000000000000000000000000000000000000000000000000000000000000",
"number":"0x65",
"parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464",
"stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111",
"timestamp":"0x6553f165",
"transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"]
}"#,
)
.unwrap();
let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap();
subscriber.latest_preconfirmation = Some(preview);
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::BaseFlashblocks,
stream::once(async {
SubscriberEvent::<Ethereum>::StreamTerminated(
SubscriberStreamSource::BaseFlashblocks,
)
})
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let batch = subscriber
.next_scoped_batch()
.await
.expect("termination handling succeeds")
.expect("invalidation is delivered");
assert!(batch.preconfirmation_invalidated());
assert!(subscriber.latest_preconfirmation.is_none());
assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8);
assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2);
assert!(
subscriber
.pending_flashblock_reconnect_sources
.iter()
.any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks))
);
assert!(
subscriber
.pending_flashblock_reconnect_sources
.iter()
.any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. }))
);
let AlloySubscriberState::Active(streams) = &subscriber.state else {
panic!("subscriber remains active while reconnect is pending")
};
assert!(
streams
.entries
.iter()
.all(|entry| !entry.source.is_flashblocks())
);
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async fn preferred_initial_flashblock_rejection_retains_canonical_streams() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let filter = Filter::new().address(Address::repeat_byte(0x42));
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
reconnect: SubscriberReconnectConfig {
enabled: false,
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 1));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: filter.clone(),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
subscriber.log_source_ids.insert(filter.clone(), 0);
subscriber.next_log_source_id = 1;
let canonical_source = SubscriberStreamSource::PubSubLog {
id: 0,
filter: filter.clone(),
};
let mut streams = SubscriberStreams::new();
streams.push(
canonical_source.clone(),
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = true;
subscriber
.ensure_streams()
.await
.expect("preferred Flashblocks setup degrades to canonical-only");
let AlloySubscriberState::Active(streams) = &subscriber.state else {
panic!("canonical stream remains active")
};
assert!(streams.contains_source(&canonical_source));
assert!(
streams
.entries
.iter()
.all(|entry| !entry.source.is_flashblocks())
);
assert!(subscriber.pending_flashblock_reconnects.is_empty());
assert!(!subscriber.sources_dirty);
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async fn required_initial_flashblock_rejection_remains_fail_closed() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let filter = Filter::new().address(Address::repeat_byte(0x42));
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Required,
reconnect: SubscriberReconnectConfig {
enabled: false,
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 1));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: filter.clone(),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
subscriber.log_source_ids.insert(filter.clone(), 0);
subscriber.next_log_source_id = 1;
let canonical_source = SubscriberStreamSource::PubSubLog {
id: 0,
filter: filter.clone(),
};
let mut streams = SubscriberStreams::new();
streams.push(
canonical_source.clone(),
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
subscriber.sources_dirty = true;
let error = subscriber
.ensure_streams()
.await
.expect_err("required Flashblocks setup must fail closed");
assert!(matches!(error, SubscriberError::Provider(_)));
let AlloySubscriberState::Active(streams) = &subscriber.state else {
panic!("the already-connected canonical stream is retained")
};
assert!(streams.contains_source(&canonical_source));
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async fn preferred_flashblock_termination_preserves_canonical_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let filter = Filter::new().address(Address::repeat_byte(0x42));
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
reconnect: SubscriberReconnectConfig {
enabled: false,
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 1));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: filter.clone(),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
subscriber.log_source_ids.insert(filter.clone(), 0);
subscriber.next_log_source_id = 1;
subscriber.sources_dirty = false;
let mut streams = SubscriberStreams::new();
streams.push(
SubscriberStreamSource::BaseFlashblocks,
stream::once(async {
SubscriberEvent::<Ethereum>::StreamTerminated(
SubscriberStreamSource::BaseFlashblocks,
)
})
.boxed(),
);
streams.push(
SubscriberStreamSource::PubSubLog {
id: 0,
filter: filter.clone(),
},
stream::once(async {
SubscriberEvent::<Ethereum>::Log {
source_id: 0,
log: rpc_log(false),
}
})
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let invalidation = subscriber
.next_scoped_batch()
.await
.expect("preferred termination does not fail")
.expect("invalidation is delivered");
assert!(invalidation.preconfirmation_invalidated());
let canonical = subscriber
.next_scoped_batch()
.await
.expect("canonical stream remains healthy")
.expect("canonical log is delivered");
assert!(!canonical.preconfirmation_invalidated());
assert_eq!(canonical.records().len(), 1);
assert_eq!(
canonical.records()[0].record.context.source,
InputSource::Subscription
);
}
#[tokio::test]
#[cfg(feature = "reactive-ws")]
async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let filter = Filter::new().address(Address::repeat_byte(0x42));
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
preconfirmations: PreconfirmationMode::Preferred,
reconnect: SubscriberReconnectConfig {
enabled: false,
..SubscriberReconnectConfig::default()
},
..SubscriberConfig::default()
},
)
.with_provider_ref(ProviderRef::new("base-paid", 1));
subscriber.chain_id = Some(8_453);
subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: filter.clone(),
local_matcher: None,
route_key: None,
})];
subscriber.interests = subscriber.base_interests.clone();
subscriber.log_source_ids.insert(filter.clone(), 0);
subscriber.next_log_source_id = 1;
subscriber.sources_dirty = false;
let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter };
let mut streams = SubscriberStreams::new();
streams.push(
canonical_source,
stream::once(async {
tokio::time::sleep(Duration::from_millis(1)).await;
SubscriberEvent::<Ethereum>::Log {
source_id: 0,
log: rpc_log(false),
}
})
.boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
let source = SubscriberStreamSource::BaseFlashblocks;
subscriber
.pending_flashblock_reconnect_sources
.push(source.clone());
subscriber
.pending_flashblock_reconnects
.push(Box::pin(async move {
(
source,
Err(SubscriberError::Provider(
"test reconnect window exhausted".to_owned(),
)),
)
}));
let canonical = subscriber
.next_scoped_batch()
.await
.expect("preferred reconnect exhaustion does not fail")
.expect("canonical log is delivered");
assert_eq!(canonical.records().len(), 1);
assert!(subscriber.pending_flashblock_reconnects.is_empty());
}
#[test]
fn backfilled_logs_skip_recent_subscription_duplicates() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})];
let log = rpc_log(false);
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: log.clone(),
});
subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
source_id: 0,
logs: vec![log],
});
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
assert_eq!(
subscriber.pending_records[0].context.source,
InputSource::Subscription
);
}
#[test]
fn backfilled_logs_surface_with_backfill_source() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.interests = vec![ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})];
subscriber.enqueue_event(SubscriberEvent::BackfilledLogs {
source_id: 0,
logs: vec![rpc_log(false)],
});
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(
subscriber.pending_records[0].context.source,
InputSource::Backfill
);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_removal_preserves_delivery_and_dedupe_state() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(
HandlerId::new("pool-a"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x42))
.event_signature(B256::repeat_byte(0x01)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-a owner");
subscriber
.add_interest_owner(
HandlerId::new("pool-b"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new()
.address(Address::repeat_byte(0x24))
.event_signature(B256::repeat_byte(0x02)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-b owner");
let sources = subscriber.stream_sources().expect("stream sources");
subscriber.enqueue_event(SubscriberEvent::Log {
source_id: 0,
log: rpc_log(false),
});
let mut streams = SubscriberStreams::new();
streams.push(
sources[0].clone(),
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.state = AlloySubscriberState::Active(streams);
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.recent_input_refs.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
let removed = subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("pool-b should be removed");
assert_eq!(removed.len(), 1);
assert_eq!(subscriber.pending_records.len(), 1);
assert_eq!(subscriber.recent_input_refs.len(), 1);
assert_eq!(subscriber.last_seen_log_blocks.get(&0), Some(&7));
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-a"))
.is_some()
);
assert!(
subscriber
.owner_interests(&HandlerId::new("pool-b"))
.is_none()
);
assert_eq!(subscriber.registered_interests().len(), 1);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_log_sources_fan_in_across_owners() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(
HandlerId::new("pool-a"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xa1)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-a owner");
let initial_sources = subscriber.stream_sources().expect("initial sources");
assert_eq!(initial_sources.len(), 1);
let pool_a_source = initial_sources[0].clone();
assert!(matches!(
&pool_a_source,
SubscriberStreamSource::PubSubLog { id: 0, .. }
));
subscriber
.add_interest_owner(
HandlerId::new("pool-b"),
&[ReactiveInterest::Logs(LogInterest {
provider_filter: Filter::new().address(Address::repeat_byte(0xb2)),
local_matcher: None,
route_key: None,
})],
)
.expect("register pool-b owner");
let expanded_sources = subscriber.stream_sources().expect("expanded sources");
assert_eq!(
expanded_sources.len(),
1,
"compatible owner filters should share one provider subscription"
);
assert!(
!expanded_sources[0].same_key(&pool_a_source),
"the provider-facing superset changes while owner routing remains exact"
);
subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("pool-b should be removed");
let trimmed_sources = subscriber.stream_sources().expect("trimmed sources");
assert_eq!(trimmed_sources.len(), 1);
assert!(trimmed_sources[0].same_key(&pool_a_source));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn provider_log_fan_in_respects_address_ceiling() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig {
max_log_addresses_per_subscription: 2,
..SubscriberConfig::default()
},
);
for index in 0..5 {
subscriber
.add_interest_owner(
HandlerId::new(format!("pool-{index}")),
&[log_interest_for(index + 1)],
)
.expect("register pool owner");
}
let sources = subscriber.stream_sources().expect("stream sources");
assert_eq!(sources.len(), 3);
let mut address_counts: Vec<_> = sources
.iter()
.map(|source| match source {
SubscriberStreamSource::PubSubLog { filter, .. } => filter.address.iter().count(),
_ => panic!("expected log source"),
})
.collect();
address_counts.sort_unstable();
assert_eq!(address_counts, vec![1, 2, 2]);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn owner_backfill_seeds_reconnect_anchor_before_live_log() {
let asserter = Asserter::new();
asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
asserter.push_success(&vec![rpc_log(false)]);
asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
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.chain_id = Some(1);
subscriber
.register_interests(&[ReactiveInterest::PendingTransactions(
PendingTxInterest::default(),
)])
.await
.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
));
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-polling")]
async fn ensure_streams_retains_each_successful_connection_across_later_failure() {
let asserter = Asserter::new();
asserter.push_success(&U256::from(1));
asserter.push_failure_msg("second filter connection failed");
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::Polling,
SubscriberConfig {
max_log_addresses_per_subscription: 1,
..SubscriberConfig::default()
},
);
subscriber.chain_id = Some(1);
subscriber
.register_interests(&[log_interest_for(0x41), log_interest_for(0x42)])
.await
.expect("register two independently connected filters");
let error = subscriber
.ensure_streams()
.await
.expect_err("second provider connection is forced to fail");
assert!(matches!(error, SubscriberError::Provider(_)));
assert!(subscriber.sources_dirty);
let retained_streams = match &subscriber.state {
AlloySubscriberState::Active(streams) => Some(streams.len()),
AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None,
};
assert_eq!(
retained_streams,
Some(1),
"first connection must survive later error {error:?}; revision {}",
subscriber.stream_revision
);
asserter.push_success(&U256::from(2));
subscriber
.ensure_streams()
.await
.expect("retry connects only the missing source");
assert!(!subscriber.sources_dirty);
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams) if streams.len() == 2
));
assert!(asserter.read_q().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(feature = "reactive-ws")]
async fn cancelled_post_install_backfill_is_retried_without_reconnecting() {
let asserter = Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber.chain_id = Some(1);
subscriber
.register_interests(&[log_interest_for(0x43)])
.await
.expect("register log source");
let source = subscriber
.stream_sources()
.expect("one desired source")
.pop()
.expect("log source");
let SubscriberStreamSource::PubSubLog { id, .. } = source else {
panic!("expected pubsub log source")
};
subscriber.last_seen_log_blocks.insert(id, 6);
{
let source = SubscriberStreamSource::PubSubLog {
id,
filter: subscriber
.log_stream_filters()
.pop()
.expect("provider filter"),
};
let interrupted = async {
subscriber.install_source_stream(
source.clone(),
stream::pending::<SubscriberEvent<Ethereum>>().boxed(),
);
subscriber.queue_source_backfill(source);
subscriber.sources_dirty = true;
futures::future::pending::<()>().await;
};
futures::pin_mut!(interrupted);
poll_fn(|cx| {
assert!(interrupted.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
}
assert_eq!(subscriber.pending_source_backfills.len(), 1);
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams) if streams.len() == 1
));
asserter.push_success(&7u64);
asserter.push_success(&Vec::<Log>::new());
subscriber
.ensure_streams()
.await
.expect("retry completes only the pending historical window");
assert!(subscriber.pending_source_backfills.is_empty());
assert!(!subscriber.sources_dirty);
assert!(matches!(
&subscriber.state,
AlloySubscriberState::Active(streams) if streams.len() == 1
));
assert!(asserter.read_q().is_empty());
}
#[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))]
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(any(feature = "reactive-ws", feature = "reactive-polling"))]
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(&Some(rpc_block(7, B256::repeat_byte(0x02))));
asserter.push_success(&vec![rpc_log(false)]);
asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02))));
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(&Some(rpc_block(42, B256::repeat_byte(42))));
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42))));
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(&Some(rpc_block(100, B256::repeat_byte(100))));
asserter.push_success(&Vec::<Log>::new()); asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100))));
let provider = ProviderBuilder::new().connect_mocked_client(asserter);
let mut subscriber = AlloySubscriber::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner_with_backfill(
HandlerId::new("pool"),
&[log_interest_matching_rpc_log()],
SubscriberBackfill::from_block(10),
)
.expect("register owner with open-ended backfill");
subscriber
.drain_pending_backfills()
.await
.expect("open-ended backfill should drain");
let filter = log_filters(subscriber.owner_interests(&HandlerId::new("pool")).unwrap())
.pop()
.unwrap();
assert_eq!(subscriber.log_anchor(&filter), Some(100));
}
#[test]
#[cfg(feature = "reactive-ws")]
fn duplicate_filters_across_owners_map_to_single_source() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
.expect("register pool-a");
subscriber
.add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xaa)])
.expect("register pool-b with identical filter");
assert_eq!(
subscriber.log_stream_filters().len(),
1,
"identical filters across owners must collapse to one"
);
let sources = subscriber.stream_sources().expect("stream sources");
assert_eq!(sources.len(), 1);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_removal_prunes_source_ids_and_anchors() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("pool-a"), &[log_interest_for(0xaa)])
.expect("register pool-a");
subscriber
.add_interest_owner(HandlerId::new("pool-b"), &[log_interest_for(0xbb)])
.expect("register pool-b");
let _ = subscriber.stream_sources().expect("stream sources");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let filter_b = log_filters(&[log_interest_for(0xbb)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
let id_b = subscriber.log_source_id(&filter_b);
subscriber.last_seen_log_blocks.insert(id_a, 10);
subscriber.last_seen_log_blocks.insert(id_b, 20);
assert_eq!(
subscriber.log_source_ids.len(),
3,
"one provider fan-in id plus two explicitly seeded logical ids"
);
subscriber
.remove_interest_owner(&HandlerId::new("pool-b"))
.expect("remove pool-b");
assert_eq!(
subscriber.log_source_ids.len(),
1,
"pool-b's filter id should be retired"
);
assert!(subscriber.log_source_ids.contains_key(&filter_a));
assert_eq!(subscriber.last_seen_log_blocks.get(&id_a), Some(&10));
assert_eq!(
subscriber.last_seen_log_blocks.get(&id_b),
None,
"pool-b's anchor should be pruned"
);
}
#[test]
#[cfg(feature = "reactive-ws")]
fn owner_filter_growth_queues_continuity_backfill_from_prior_anchor() {
let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
let mut subscriber = AlloySubscriber::<_, Ethereum>::new(
provider,
SubscriberMode::PubSub,
SubscriberConfig::default(),
);
subscriber
.add_interest_owner(HandlerId::new("amm"), &[log_interest_for(0xaa)])
.expect("register amm with pool A");
let filter_a = log_filters(&[log_interest_for(0xaa)]).pop().unwrap();
let id_a = subscriber.log_source_id(&filter_a);
subscriber.last_seen_log_blocks.insert(id_a, 50);
subscriber
.add_interest_owner(
HandlerId::new("amm"),
&[log_interest_for(0xaa), log_interest_for(0xbb)],
)
.expect("grow amm to pools A+B");
assert_eq!(
subscriber.pending_backfills.len(),
1,
"the changed merged filter should queue exactly one continuity backfill"
);
let queued = &subscriber.pending_backfills[0];
assert_eq!(queued.owner, Some(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.preconfirmations != PreconfirmationMode::Disabled
&& config.canonical_head_poll_interval.is_zero()
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::canonical_head_poll_interval must be greater than zero",
));
}
if config.preconfirmations != PreconfirmationMode::Disabled
&& config.canonical_head_request_timeout.is_zero()
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::canonical_head_request_timeout must be greater than zero",
));
}
if config.preconfirmations != PreconfirmationMode::Disabled
&& config.flashblock_poll_interval.is_zero()
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::flashblock_poll_interval must be greater than zero",
));
}
if config.preconfirmations != PreconfirmationMode::Disabled
&& config.max_consecutive_flashblock_poll_failures == 0
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero",
));
}
if config.preconfirmations != PreconfirmationMode::Disabled
&& config.max_pending_transaction_receipts_per_tick == 0
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero",
));
}
if config.preconfirmations != PreconfirmationMode::Disabled
&& config.max_flashblock_rpc_requests_per_second == 0
{
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero",
));
}
if config.max_batch_size == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_batch_size must be greater than zero",
));
}
if config.max_log_addresses_per_subscription == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_log_addresses_per_subscription must be greater than zero",
));
}
if config.max_pending_records == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_pending_records must be greater than zero",
));
}
if config.max_pending_backfills == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_pending_backfills must be greater than zero",
));
}
if config.max_backfill_log_bytes == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_backfill_log_bytes must be greater than zero",
));
}
if config.max_reconcile_requests_in_flight == 0 {
return Err(SubscriberError::InvalidConfig(
"SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero",
));
}
if config.reconnect.enabled {
if config.reconnect.retry_delay > config.reconnect.max_delay {
return Err(SubscriberError::InvalidConfig(
"SubscriberReconnectConfig::retry_delay must be less than or equal to max_delay",
));
}
if matches!(config.reconnect.max_attempts, Some(0)) {
return Err(SubscriberError::InvalidConfig(
"SubscriberReconnectConfig::max_attempts must be greater than zero when set",
));
}
}
Ok(())
}
fn validate_supported_interests<N: Network>(
mode: SubscriberMode,
config: &SubscriberConfig,
interests: &[ReactiveInterest<N>],
) -> Result<(), SubscriberError> {
let transport = resolve_subscriber_transport(mode)?;
for interest in interests {
match interest {
ReactiveInterest::Logs(_) => {}
ReactiveInterest::PendingTransactions(interest)
if !config.hydrate_pending_transactions && interest.matches_hash_only() => {}
ReactiveInterest::PendingTransactions(_) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber currently supports pending transaction hash interests only (full pending-tx hydration is unimplemented)",
));
}
ReactiveInterest::Blocks(interest) => match (transport, interest.mode) {
(SubscriberTransport::PubSub, BlockInterestMode::Header) => {}
(_, BlockInterestMode::FullBlock) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber full block streams are not implemented in this transport slice",
));
}
(SubscriberTransport::Polling, BlockInterestMode::Header) => {
return Err(SubscriberError::Unsupported(
"AlloySubscriber polling block streams are not implemented in this transport slice",
));
}
},
}
}
Ok(())
}
fn log_filters<N: Network>(interests: &[ReactiveInterest<N>]) -> Vec<Filter> {
let mut filters = Vec::new();
for interest in interests {
if let ReactiveInterest::Logs(interest) = interest {
merge_log_subscription_filter(&mut filters, &interest.provider_filter);
}
}
filters
}
fn needs_header_block_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::Blocks(BlockInterest {
mode: BlockInterestMode::Header,
})
)
})
}
fn needs_pending_hash_stream<N: Network>(interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::PendingTransactions(interest) if interest.matches_hash_only()
)
})
}
fn log_matches_any_interest<N: Network>(log: &Log, interests: &[ReactiveInterest<N>]) -> bool {
interests.iter().any(|interest| {
matches!(
interest,
ReactiveInterest::Logs(interest) if interest.matches(log)
)
})
}
fn validate_owner_backfill_logs(
logs: &[Log],
from_block: u64,
through: &BlockRef,
) -> Result<(), SubscriberOwnerError> {
for log in logs {
if log.removed {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"removed log in canonical catch-up",
));
}
let number = log
.block_number
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing block number",
))?;
let hash = log
.block_hash
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing block hash",
))?;
log.transaction_hash
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing transaction hash",
))?;
log.transaction_index
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing transaction index",
))?;
log.log_index
.ok_or(SubscriberOwnerError::InvalidBackfillLog(
"log missing log index",
))?;
if number < from_block || number > through.number {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"log outside requested block range",
));
}
if number == through.number && hash != through.hash {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"target-block log hash mismatch",
));
}
}
Ok(())
}
fn validate_backfill_resource_limits(
logs: &[Log],
max_logs: usize,
max_log_bytes: usize,
) -> Result<usize, SubscriberError> {
if logs.len() > max_logs {
return Err(SubscriberError::ResourceExhausted(format!(
"historical response returned {} logs, above the configured limit of {max_logs}",
logs.len()
)));
}
let bytes = logs.iter().fold(0usize, |total, log| {
let fixed = 20usize + (32 * 3) + (8 * 4) + 1;
total
.saturating_add(fixed)
.saturating_add(log.topics().len().saturating_mul(32))
.saturating_add(log.inner.data.data.len())
});
if bytes > max_log_bytes {
return Err(SubscriberError::ResourceExhausted(format!(
"historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}"
)));
}
Ok(bytes)
}
async fn fetch_provider_block_ref<P, N>(
provider: &P,
number: u64,
) -> Result<BlockRef, SubscriberError>
where
P: Provider<N> + Send + Sync,
N: Network,
{
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(number))
.await
.map_err(provider_error)?
.ok_or_else(|| {
SubscriberError::InvalidBackfill(format!(
"canonical target block {number} is unavailable"
))
})?;
let header = block.header();
Ok(BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
})
}
fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool {
actual.number == expected.number
&& actual.hash == expected.hash
&& optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref())
&& optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref())
}
fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> {
let mut positions = HashMap::new();
let mut block_hashes = HashMap::new();
let mut transaction_hashes = HashMap::new();
let mut transaction_positions = HashMap::new();
let mut ordering = BTreeMap::<u64, Vec<(u64, u64)>>::new();
for log in logs {
let number = log
.block_number
.expect("individual owner catch-up logs are validated before set validation");
let block_hash = log
.block_hash
.expect("individual owner catch-up logs are validated before set validation");
let transaction_hash = log
.transaction_hash
.expect("individual owner catch-up logs are validated before set validation");
let transaction_index = log
.transaction_index
.expect("individual owner catch-up logs are validated before set validation");
let log_index = log
.log_index
.expect("individual owner catch-up logs are validated before set validation");
if block_hashes
.insert(number, block_hash)
.is_some_and(|prior| prior != block_hash)
{
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting block identity in canonical catch-up",
));
}
if let Some(previous) = positions.insert((number, log_index), log)
&& previous != log
{
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting logs at one canonical block position",
));
}
let conflicting_transaction = transaction_hashes
.insert((number, transaction_index), transaction_hash)
.is_some_and(|prior| prior != transaction_hash)
|| transaction_positions
.insert((number, transaction_hash), transaction_index)
.is_some_and(|prior| prior != transaction_index);
if conflicting_transaction {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"conflicting transaction identity at one canonical block position",
));
}
ordering
.entry(number)
.or_default()
.push((log_index, transaction_index));
}
for positions in ordering.values_mut() {
positions.sort_unstable();
if positions.windows(2).any(|pair| pair[0].1 > pair[1].1) {
return Err(SubscriberOwnerError::InvalidBackfillLog(
"transaction and log positions disagree on canonical order",
));
}
}
Ok(())
}
fn merged_owner_reconcile_filters<N: Network>(
plans: &[SubscriberOwnerReconcilePlan<N>],
through: u64,
) -> Vec<SubscriberOwnerReconcileFilter> {
let mut by_start = BTreeMap::<u64, Vec<Filter>>::new();
for plan in plans.iter().filter(|plan| plan.from_block <= through) {
let filters = by_start.entry(plan.from_block).or_default();
filters.extend(
log_filters(&plan.interests)
.into_iter()
.map(|filter| filter.from_block(plan.from_block).to_block(through)),
);
}
let mut chunks = Vec::new();
for (from_block, filters) in by_start {
for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
let mut merged = Vec::new();
for filter in filters {
merge_log_subscription_filter(&mut merged, filter);
}
chunks.extend(
merged
.into_iter()
.map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
);
}
}
chunks
}
fn merged_lazy_backfill_filters(
filters: &[Filter],
from_block: u64,
through: u64,
) -> Vec<SubscriberOwnerReconcileFilter> {
let mut requests = Vec::new();
for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) {
let mut merged = Vec::new();
for filter in filters {
merge_log_subscription_filter(
&mut merged,
&filter.clone().from_block(from_block).to_block(through),
);
}
requests.extend(
merged
.into_iter()
.map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }),
);
}
requests
}
fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError {
match error {
SubscriberOwnerError::Subscriber(error) => error,
error => SubscriberError::InvalidBackfill(error.to_string()),
}
}
fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl {
let mut id = b"alloy-global-backfill-v1".to_vec();
id.extend_from_slice(&backfill.start_block().to_be_bytes());
id.extend_from_slice(&certified.number.to_be_bytes());
id.extend_from_slice(certified.hash.as_slice());
ChainControl::Barrier {
id,
block: Some(certified),
}
}
async fn fetch_owner_catchup<P, N>(
provider: P,
filters: Vec<SubscriberOwnerReconcileFilter>,
retained: Vec<BlockRef>,
through: BlockRef,
options: SubscriberOwnerCatchupOptions,
) -> Result<SubscriberOwnerCatchup, SubscriberOwnerError>
where
P: Provider<N> + Send + Sync,
N: Network,
{
if !options.target_preverified {
let _ = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
}
let mut certified_positions = HashSet::new();
for position in retained {
let target_certifies_position = position == through
|| (position.number.checked_add(1) == Some(through.number)
&& through.parent_hash == Some(position.hash));
if !target_certifies_position && certified_positions.insert(position) {
let _ = verify_provider_reconcile_target::<P, N>(&provider, &position).await?;
}
}
let mut logs = Vec::new();
let mut total_log_bytes = 0usize;
let requests = stream::iter(filters.into_iter().map(|filter| {
let provider = &provider;
async move {
let logs = provider
.get_logs(&filter.filter)
.await
.map_err(provider_error)?;
Ok::<_, SubscriberOwnerError>((filter.from_block, logs))
}
}))
.buffer_unordered(options.max_requests_in_flight);
futures::pin_mut!(requests);
while let Some(result) = requests.next().await {
let (from_block, fetched) = result?;
let fetched_bytes =
validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?;
validate_owner_backfill_logs(&fetched, from_block, &through)?;
if logs.len().saturating_add(fetched.len()) > options.max_logs {
return Err(SubscriberError::ResourceExhausted(format!(
"bulk reconcile returned more than {} logs",
options.max_logs
))
.into());
}
total_log_bytes = total_log_bytes.saturating_add(fetched_bytes);
if total_log_bytes > options.max_log_bytes {
return Err(SubscriberError::ResourceExhausted(format!(
"bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}",
options.max_log_bytes
))
.into());
}
logs.extend(fetched);
}
validate_owner_backfill_log_set(&logs)?;
let certified = verify_provider_reconcile_target::<P, N>(&provider, &through).await?;
Ok(SubscriberOwnerCatchup { logs, certified })
}
async fn verify_provider_reconcile_target<P, N>(
provider: &P,
expected: &BlockRef,
) -> Result<BlockRef, SubscriberOwnerError>
where
P: Provider<N> + Send + Sync,
N: Network,
{
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(expected.number))
.await
.map_err(provider_error)?
.ok_or(SubscriberOwnerError::BlockUnavailable(expected.number))?;
let header = block.header();
let actual = BlockRef {
number: header.number(),
hash: header.hash(),
parent_hash: Some(header.parent_hash()),
timestamp: Some(header.timestamp()),
};
let exact_parent = expected
.parent_hash
.is_none_or(|parent| Some(parent) == actual.parent_hash);
let exact_timestamp = expected
.timestamp
.is_none_or(|timestamp| Some(timestamp) == actual.timestamp);
if actual.number != expected.number
|| actual.hash != expected.hash
|| !exact_parent
|| !exact_timestamp
{
return Err(SubscriberOwnerError::BlockMismatch {
expected_number: expected.number,
expected_hash: expected.hash,
actual_number: actual.number,
actual_hash: actual.hash,
});
}
Ok(actual)
}
fn log_input_record<N: Network>(log: Log, source: InputSource) -> ReactiveInputRecord<N> {
let context = log_reactive_context(&log);
ReactiveInputRecord::new(
ReactiveInput::Log(log),
ReactiveContext { source, ..context },
)
}
fn preconfirmed_log_input_record<N: Network>(
log: Log,
flashblock: FlashblockRef,
) -> ReactiveInputRecord<N> {
let block = flashblock.block_ref();
let provider = flashblock.provider.clone();
ReactiveInputRecord::new(
ReactiveInput::Log(log.clone()),
ReactiveContext {
chain_id: None,
source: InputSource::Flashblocks,
chain_status: ChainStatus::Preconfirmed {
flashblock: Arc::new(flashblock),
},
block: Some(block),
transaction_index: log.transaction_index,
log_index: log.log_index,
},
)
.with_provider(provider)
}
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,
},
(Some(block), false) => ChainStatus::Included {
block: *block,
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,
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,
},
)
}
#[cfg(feature = "reactive-ws")]
fn base_pending_log_filter(filter: &Filter) -> Result<serde_json::Value, SubscriberError> {
let encoded = serde_json::to_value(filter)
.map_err(|error| SubscriberError::Provider(error.to_string()))?;
let serde_json::Value::Object(mut fields) = encoded else {
return Err(SubscriberError::Provider(
"Alloy log filter did not serialize as an object".into(),
));
};
fields.retain(|key, _| key == "address" || key == "topics");
Ok(serde_json::Value::Object(fields))
}
fn provider_error(error: impl fmt::Display) -> SubscriberError {
SubscriberError::Provider(error.to_string())
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SubscriberError {
#[error("{0}")]
InvalidConfig(&'static str),
#[error("{0}")]
Unsupported(&'static str),
#[error("subscriber chain mismatch: expected {expected}, got {actual}")]
ChainMismatch {
expected: u64,
actual: u64,
},
#[error("provider error: {0}")]
Provider(String),
#[error("invalid canonical backfill: {0}")]
InvalidBackfill(String),
#[error("subscriber resource limit exceeded: {0}")]
ResourceExhausted(String),
}