ethl 0.1.44

Tools for capturing, processing, archiving, and replaying Ethereum events
Documentation
use alloy::primitives::B256;
use alloy::rpc::types::Log;
use thiserror::Error;

use crate::storage::codec::decoder::DecodedEventWithHeader;

pub mod backfill;
pub mod config;
pub(crate) mod cursor;
pub mod error_tracking;
pub mod events;
pub mod heads;
pub mod progress;
pub mod provider;
pub mod stream;

#[derive(Debug, Clone, Error)]
pub enum RpcError {
    #[error("Connection Error: {0}")]
    ConnectionError(String),
    #[error("Subscription Error: {0}")]
    SubscriptionError(String),
    #[error("Log Fetch Error: {0}")]
    LogFetchError(String),
    #[error("Transport Error: {0}")]
    TransportError(String),
    #[error("All Providers Suspended: {0}")]
    AllProvidersSuspended(String),
    /// Internal-only signal — produced by `next_cursor_after_batch`, intercepted at
    /// the three call sites, never yielded to consumers. See `ProviderStalled` for the
    /// terminal variant that consumers receive when a provider tip is genuinely stalled.
    #[error(
        "Cursor past tip: refusing to advance to block {to_block}; provider reports tip {reported_tip}"
    )]
    CursorPastTip { to_block: u64, reported_tip: u64 },
    #[error(
        "provider stalled: tip {reported_tip} did not reach block {to_block} after {stalled_cycles} cycles"
    )]
    ProviderStalled {
        to_block: u64,
        reported_tip: u64,
        stalled_cycles: u32,
    },
    /// Every configured WS provider was tried in turn and each delivered no
    /// head within the idle bound — a full quiet rotation. A structural
    /// observation only: it makes no claim about chain health, since a chain
    /// can be legitimately quiet. Retryable by contract; the consumer
    /// decides whether to restart or treat the quiet as expected.
    #[error(
        "all {providers_tried} provider(s) idle: no head within {idle_secs}s after a full rotation"
    )]
    AllProvidersIdle {
        providers_tried: usize,
        idle_secs: u64,
    },
    /// A tip reorg detected by the live `parent_hash` linkage. `common_ancestor`
    /// is the highest block the new chain still shares with what we recorded — the
    /// single value a consumer's rollback acts on. `depth` (detection point minus
    /// ancestor) and the competing hashes drive no decision, so they are not carried.
    #[error("reorg: common ancestor at block {common_ancestor}")]
    Reorg { common_ancestor: u64 },
    /// A canonical hash we recorded at the recovery ring's floor disagrees
    /// with the chain the reorg walk descended: the ancestor lies beyond what
    /// we retained. A claim about the chain, and it is earned: raised only
    /// after a fetched header's hash was compared against a recorded one and
    /// they differed. No common ancestor is carried because none was
    /// established — the recovery anchor is the consumer's to find against
    /// state it actually applied.
    #[error("reorg beyond ring: a recorded canonical hash disagrees with the chain we walked")]
    ReorgBeyondRing,
    /// The live sequence did not extend cleanly and ethl could not establish
    /// why: heads skipped ahead of the recovery ring, no canonical record at
    /// the parent's height, or a walk that stopped without deciding. A claim
    /// about our own view, never about the chain — it asserts no fork, no
    /// depth and no finality. The consumer re-establishes an anchor from
    /// state it applied and resumes there.
    #[error("gap: the live sequence did not extend cleanly")]
    Gap,
    #[error("Other Error: {0}")]
    Other(String),
}

/// One contiguous block range of raw logs, as yielded by every log stream.
///
/// `tip_hash` is the canonical hash of `to_block`, carried so a consumer can
/// anchor recovery on the exact block whose events it applied rather than
/// re-fetching a hash by number later, possibly from a different provider.
/// `Some` only on the by-hash paths, which fetched with that hash and so know
/// it; `None` on backfill, which fetches by number and never learns it. A
/// `None` range is complete and correct data — it simply cannot serve as a
/// recovery anchor.
///
/// Comparison and hashing carry over from the tuple this replaced, which had
/// them because `Log` derives `PartialEq`, `Eq` and `Hash`. `Default` and
/// serde, which that tuple also offered — `Log` derives `Default` and, under
/// the `serde` feature this workspace enables, `Serialize`/`Deserialize` —
/// are not carried over. No consumer asks for either, and restoring them
/// widens the public contract rather than repairing it.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LogRange {
    pub from_block: u64,
    pub to_block: u64,
    pub logs: Vec<Log>,
    pub tip_hash: Option<B256>,
}

/// [`LogRange`] after decoding, as yielded by `EventStreamer::stream`.
///
/// `tip_hash` carries the same meaning and the same `Some`/`None` rule as
/// [`LogRange`]'s, unchanged by the decode: a range whose blocks decode to
/// zero events still has a canonical identity.
///
/// Carries no `PartialEq`, and neither did the tuple this replaced:
/// `DecodedEventWithHeader` has never derived one, though every field it
/// holds would support it. `Eq` and `Hash` are out of reach whatever we do
/// here — `DecodedEvent` derives `PartialEq` alone. Comparing ranges past the
/// decode needs `DecodedEventWithHeader` to gain the derive first.
#[derive(Debug, Clone)]
pub struct DecodedEventRange {
    pub from_block: u64,
    pub to_block: u64,
    pub events: Vec<DecodedEventWithHeader>,
    pub tip_hash: Option<B256>,
}