Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

blockwatcher-evm

blockwatcher-evm is the EVM chain family: two sources: evm-rpc, which polls JSON-RPC for confirmed blocks and misses nothing on the chain it watches, and evm-mempool, which subscribes to one node’s pending-transaction feed and reports a transaction before anyone knows whether it will be mined, plus the evm decoder both sources feed (crates/blockwatcher-evm/src/lib.rs). An operator selects one source per network; they are never combined in one pipeline. It is a module crate, built on blockwatcher-rpc’s endpoint pool and the alloy ABI-decoding SDK.

Selectors and Delivery guarantees already document this family at an operator’s level in depth: what events and functions each decode and hand to a predicate, how each source acquires its raw material, each source’s cursor semantics (evm-rpc’s block-number-plus-ordering-bit position; evm-mempool’s per-run arrival counter, its dedupe-by-tx.hash consumer contract, and tx.index being usually-but-not-always absent), and why evm-mempool sits outside at-least-once delivery. This page does not restate any of that: it maps the same facts onto the crate’s actual module layout: which file owns which piece of the pipeline from an RPC endpoint down to a canonical decoded value, the registry that wires it all up, and the decoder’s own compile-once/decode-many mechanics.

Key takeaways

  • blockwatcher-evm is the EVM chain family: two sources, evm-rpc (confirmed blocks, misses nothing) and evm-mempool (pending transactions, no guarantee of mining), plus the one evm decoder both feed.
  • An operator selects one source per network; the two are never combined in one pipeline.
  • This page maps the operator-level facts already documented on Selectors and Delivery guarantees onto the crate’s actual file layout, rather than restating them.

Responsibilities

  • evm-rpc (source::rpc::EvmRpcSource, source/rpc/): poll a pool of JSON-RPC endpoints for confirmed blocks and their logs, detect and recover from reorgs against a bounded recent-block memory, and emit both a block’s logs and, only while some monitor watches functions, its matching transactions, on one non-decreasing cursor stream.
  • evm-mempool (source::mempool::EvmMempoolSource, source/mempool/): subscribe to one node’s newPendingTransactions feed over WebSocket, hydrate each candidate hash’s calldata through the same kind of endpoint pool, and forward it on an arrival-counter cursor with no history and no reorg concept.
  • EvmDecoder (decoder/): translate a Solidity JSON ABI into chain-agnostic schemas exactly once, at spec-compile time, and decode a raw log or transaction against the compiled result on the hot path.
  • Compile a monitor’s selectors into a per-address, per-topic0/per-4-byte-selector dispatch table, and derive the fetch-narrowing interest hints a source may use from it (decoder/selector.rs).
  • Register evm-rpc, evm-mempool, and the evm decoder under the single registration convention every module family follows, and resolve each endpoint’s url_secret into a value neither this crate nor blockwatcher-rpc ever logs or stores (registry.rs).

Not this crate’s job: running a pipeline, retrying a delivery, or deciding when a checkpoint is safe to persist: blockwatcher-core owns all of that; pooling, retrying, or health-checking an RPC connection at the transport level: blockwatcher-rpc owns endpoint selection, circuit breaking, rate limiting, and retry-by-error-class, and this crate only ever supplies the connection type and the error classification blockwatcher-rpc::Pool<C> asks for; parsing an env:NAME secret reference: blockwatcher_types::SecretRef owns that mechanism; this crate only calls it, once, at source construction (see Endpoints and the env:/url_secret indirection below); defining the Decoder/Source port traits or the vocabulary types they move: blockwatcher-ports, blockwatcher-types.

Key types and traits

NameKindRole
EvmDecoderstructThe EVM Decoder port implementation (decoder/mod.rs)
source::rpc::EvmRpcSourcestructThe evm-rpc Source port implementation (source/rpc/run.rs)
source::mempool::EvmMempoolSourcestructThe evm-mempool Source port implementation (source/mempool/run.rs)
EvmIntereststructThe EVM-typed extension of InterestSet::chain_specific: the exact 4-byte function selectors some monitor wants (interest.rs)
jsonrpc::EvmEndpointstructOne JSON-RPC endpoint: a reqwest::Client plus its resolved provider url::Url, the connection type both sources parameterize blockwatcher_rpc::Pool with (jsonrpc.rs)
jsonrpc::EvmRpcErrorenumEvery provider failure this family crosses a boundary as; implements Classify for blockwatcher-rpc’s retry policy (jsonrpc.rs)
source::endpoint::EndpointDefstructOne endpoint’s operator-facing config: name, url_secret, priority, optional rate limit, shared by both sources (source/endpoint.rs)
source::endpoint::EndpointPriorityenumHigh/Low selection tier, the wire form of blockwatcher_rpc::Priority (source/endpoint.rs)
source::rpc::config::EvmRpcConfigstructevm-rpc’s boot-time config: endpoints, start_block, confirmations, lag tolerance, poll interval, logs window, receipt policy (source/rpc/config.rs)
source::rpc::config::ReceiptPolicyenumAlways/WhenRead: when a matching transaction’s receipt is worth fetching for tx.status alone (source/rpc/config.rs)
source::mempool::config::EvmMempoolConfigstructevm-mempool’s boot-time config: subscription URL, hydration endpoints, reconnect interval, and a nested idle_policy (source/mempool/config.rs)
source::mempool::config::IdlePolicyDefstructThe wire form of ws::IdlePolicy: ping_after_ms / pong_deadline_ms, when to suspect a half-open subscription socket and how long to wait for proof (source/mempool/config.rs)
source::rpc::chain::RecentChainstructBounded memory of recently seen (block, hash) pairs, used to verify a fetched window still lines up (source/rpc/chain.rs)
source::rpc::chain::Header, LinkageBreakstructOne block’s number/hash/parent, and the two distinct ways a fetched window can fail to attach: an inconsistent endpoint versus a real reorg (source/rpc/chain.rs)
registry::EvmRpcRegistry, EvmMempoolRegistry, EvmDecoderRegistrystructThe three ModuleRegistry implementations this crate registers (registry.rs)
registry::sources::get_all, registry::decoders::get_all (sources/decoders at crate root)fnFamily enumerations a composition root folds into its module catalog (registry.rs, re-exported lib.rs)

SpecInner, SelectorInner/Entry, EventPlan/FunctionPlan, NamePlan, and every type inside decoder/selector.rs, decoder/plan.rs, and decoder/abi_types.rs are pub(crate) or narrower, reachable only through CompiledSpec/CompiledSelector’s type-erased downcast: none of them is part of this crate’s external surface, regardless of any inner pub marking on the type itself. The same is true of every type in scan.rs (pub(crate), despite scan being a pub mod) and the entire ws.rs module, which sits behind a private mod ws; in lib.rs.

From an RPC endpoint to a canonical value

Both sources and the decoder agree on one boundary type, blockwatcher_types::RawEvent (crates/blockwatcher-types/src/event.rs:17-27):

#![allow(unused)]
fn main() {
pub struct RawEvent {
    pub network: NetworkId,
    pub chain: ChainKind,
    pub cursor: Cursor,
    pub payload: RawPayload,
    /// Per-cursor resume payload, opaque to core: the engine threads it into
    /// the persisted `Checkpoint` and hands it back to the source at resume,
    /// unread and uninterpreted along the way.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_state: Option<serde_json::Value>,
}
}

chain: ChainKind is an open string tag ("evm" here, never an enum; see crates/blockwatcher-types/src/id.rs), and payload: RawPayload is either Json(serde_json::Value) or Bytes(Vec<u8>) (blockwatcher-types/src/event.rs). Both sources in this family always produce RawPayload::Json, an untyped log or transaction object taken straight off the wire, never a chain-specific Rust struct. Nothing outside this family’s own decoder ever looks inside that JSON value; the tag is what lets blockwatcher-core route the payload to the one decoder registered for "evm" without itself understanding a single field of it.

flowchart LR
    subgraph endpoint["RPC endpoint(s)"]
        rpc_ep["evm-rpc pool<br/>eth_getLogs / eth_getBlockByNumber<br/>/ eth_getTransactionReceipt"]
        mem_ep["evm-mempool pool<br/>newPendingTransactions (ws)<br/>+ eth_getTransactionByHash (http)"]
    end
    subgraph sources["source::{rpc,mempool}"]
        rpcsrc["EvmRpcSource<br/>cursor = block# + ordering bit"]
        memsrc["EvmMempoolSource<br/>cursor = arrival counter"]
    end
    subgraph raw["opaque, ChainKind-tagged payload"]
        re["RawEvent<br/>{ chain: 'evm', cursor,<br/>payload: RawPayload::Json(Value) }"]
    end
    subgraph dec["decoder::EvmDecoder"]
        disp["decode()<br/>dispatch: topics -> log, input -> tx"]
        plan["EventPlan / FunctionPlan<br/>(built once, at compile_spec)"]
        toval["to_value()<br/>DynSolValue -> canonical Value"]
    end
    subgraph out["canonical values"]
        de["DecodedEvent<br/>{ kind, name, fields: Value, cursor }"]
    end

    rpc_ep --> rpcsrc --> re
    mem_ep --> memsrc --> re
    re --> disp
    plan -.->|"read-only,<br/>built at write time"| disp
    disp --> toval --> de

decode dispatches purely on the shape of the JSON payload, never on which source produced it: an object carrying topics is a log, one carrying input (and no topics) is a transaction, and nothing ever carries both (decoder/decode.rs). This is the exact mechanism behind Selectors § The source’s account of how both sources feed one decoder. to_value (decode.rs) is the ABI-to-canonical step proper: it walks an alloy_dyn_abi::DynSolValue against the NamePlan built for it at compile time and produces blockwatcher_types::Value (Int/Uint via BigInt/BigUint from the 32-byte big-endian form, Address/Bytes, Str, Array/Map recursively). This crate’s decoder never invents a value shape of its own; it only ever produces the canonical tree every other chain family’s decoder also produces.

The sources

evm-rpc: acquiring confirmed blocks

One file per concern under source/rpc/:

File-by-file map: source/rpc/
FileOwns
config.rsEvmRpcConfig: endpoints, selection (ordered default, or round_robin to spread windows across a tier’s endpoints, weighted by each endpoint’s weight; only a window’s first, unpinned call is affected — the rest stay pinned to whichever endpoint served it), start_block (required, never head-relative; see the field’s own doc comment on why an absolute block replays rather than silently skips a gap), confirmations (default 12), max_lag_blocks (default 3), poll_interval_ms (default 3_000), logs_window (LogsWindow { initial: 512, max: 2048 }), full_block_window (default 8, the ceiling on how wide a range fetched with transaction bodies is ever requested), probe_interval_ms (default 10_000), retry_backoff_max_ms (default 30_000, the ceiling on the doubling retry delay for a window the run loop could not fetch), receipts (ReceiptPolicy, default Always), receipt_concurrency (default 4, how many eth_getTransactionReceipt calls one leaf may have in flight at once), header_batch (default 20, how many eth_getBlockByNumber requests ride one JSON-RPC batch), bloom_screen (default true, whether to skip a window’s eth_getLogs call when every fetched header’s logsBloom proves no monitored address or topic0 can be present) (config.rs).
backoff.rsRetryBackoff, the pacing for retrying a window the run loop could not fetch: the first delay is one poll interval, doubling per consecutive failure up to retry_backoff_max_ms; a provider’s Retry-After (delta-seconds form, surfaced by ScanError::retry_after) raises a delay toward the same ceiling but never past it, and one served window resets the schedule (backoff.rs).
chain.rsRecentChain, a bounded memory (clamp(2 * confirmations, 8, 64) entries) of recently seen (block, hash) pairs, and verify_extends’s two-way failure classification: a window whose own headers don’t chain into each other (an inconsistent endpoint) versus one that chains internally but doesn’t attach to the tracked tip (a genuine reorg) (chain.rs).
scan.rsLogFilter/Window/WindowData/ScanError (scan.rs), then the single-endpoint window fetch and provider-forced-range-split machinery shared by both the live poll loop and the Source::scan dry run (POST /monitors/{id}/test’s fetch mode): fetch_window_on (scan.rs) is the real fetch logic, and fetch_split (scan.rs) is a thin wrapper over fetch_split_boxed (scan.rs), which recursively bisects a window a provider refuses as too wide. fetch_header_only (scan.rs) fetches and parses one block’s header with no eth_getLogs call at all, for a caller that only wants to know a block’s identity.
emit.rsTurns one fetched window into RawEvents: pack_secondary/SECONDARY_KIND_TX/SECONDARY_KIND_LOG pack the cursor’s secondary component so every transaction in a block sorts below every log sharing it (emit.rs); emit_verified_leaf walks a block’s transactions first, then its logs, in that order, for the streaming path, and scan_range does the identical thing for the dry-run path so the two can never disagree on cursor or payload shape (emit.rs).
resume.rsEvmRpcSource::resume, which never trusts a persisted checkpoint’s block outright: re-fetches that block’s header, compares hashes, and on mismatch walks the persisted RecentChain backward against freshly fetched headers. A still-matching ancestor is a proven fork and returns Invalidated { from } so the engine can retract. A walk that matches no tracked ancestor is beyond_window: every tracked entry names a block the live chain denies, so divergence is proven at or below the oldest tracked height and resume returns Invalidated { from } just below that height, a rewind bounded by the tracker’s own depth, never genesis. Each step of that walk, and resume’s own initial checkpoint check, is one probe of fetch_one_header, which costs one eth_getBlockByNumber and no log call (resume.rs).
run.rsEvmRpcSource itself and its Source::run loop: head probing, the confirmation-depth barrier, window-size growth and shrink, and routing a LinkageBreak. A break inside a window never before emitted is retried in place (within_confirmations). A break against already-emitted work returns Invalidated { from } when a tracked ancestor still matches (beyond_confirmations), and when none does (beyond_window) returns Invalidated { from } just below the oldest tracked height, the same bounded rewind resume uses (run.rs).

Whether a poll cycle fetches full transaction bodies at all is itself interest-driven: with no monitor watching any functions selector, the source never asks for them, and the wire shape it produces stays exactly the header-plus-logs profile: the check that gates this is evm_interest(interest).is_some() at emit.rs, read once per cycle from the merged InterestSet the pipeline hands the source.

What a window costs: receipt_concurrency and header_batch

Two knobs govern how many round trips a window spends and how they are paced. Neither changes what a window produces:

  • receipt_concurrency (default 4): how many eth_getTransactionReceipt calls one leaf’s matching transactions may have in flight at once (emit.rs). A receipt is an unpinned consensus read of an already confirmed block, so concurrency moves pacing alone, never which endpoint’s view a window carries; each branch is the same Degraded-retried fetch a serial pass runs, and the per-endpoint rate limiter and circuit breaker still gate every call. A higher value shortens a function-heavy leaf at the cost of burstier provider load.
  • header_batch (default 20): how many eth_getBlockByNumber requests ride one JSON-RPC batch (scan.rs). A window of w blocks costs ceil(w / header_batch) header round trips rather than w of them. A batch is one HTTP request against one endpoint, so it carries the window’s pin and fails the whole window exactly as a lone request does: the batch size changes how many requests a window costs, never how many endpoints it reads. This is also the ceiling past which widening full_block_window stops amortising header calls and only keeps amortising the filter call.

The default value is a wire-shape decision, and it changes the shape this source posts by default: at any value above 1, a window’s headers go out as a JSON-RPC batch array rather than as one request object per block. A provider that rejects batch arrays therefore fails its very first window, loudly: the source publishes Degraded, the run loop retries that window on its doubling backoff schedule, and the pipeline makes no progress until an operator sets header_batch = 1 for that network. That value is the escape hatch. It restores the single-call wire shape byte for byte instead of sending one-member batches, which is what makes it a genuine fallback for a provider that refuses the batch form at all.

This cost model is for a window: a range fetched to emit events from. A resume or deep-reorg probe fetches no events, only a single block’s identity, so it never pays a window’s shape at all: each probe costs one eth_getBlockByNumber and no log call, regardless of header_batch or bloom_screen, via scan.rs’s fetch_header_only. A deep reorg walk runs this probe once per tracked height it checks, so its total cost scales with how far back the walk has to look, never with a window’s width or filter.

Removing the call entirely: bloom_screen

receipt_concurrency and header_batch pace a window’s round trips; bloom_screen (default true) can remove one of them outright. Once a window’s headers are in hand, each header’s logsBloom is checked against the filter’s addresses and topic0 signatures before eth_getLogs is ever called: if every header’s bloom refutes every monitored address, or every monitored topic0, the filter call is skipped and the window is treated as holding no logs, since eth_getLogs requires both to hold and either dimension refuted alone already rules out a match. Against a protocol-conforming node the skip is lossless: its bloom is a superset of its logs, so no log the filter would have matched can hide behind a bloom that already ruled it out. Enabling the screen nonetheless adds a dependency the unscreened path does not carry: correctness now rests on the bloom as well as on the logs response, so a node or caching proxy that serves correct logs behind a zeroed or otherwise-inaccurate bloom loses those logs silently, the same symptom named in Troubleshooting. The setting trades one saved eth_getLogs call for that dependency.

The screen only ever applies where it has something to refute: a header whose bloom cannot be read is never screened regardless of this setting, and a filter with no addresses and no topic0s is never screened either, since there is nothing in it for a bloom to refute. The dry-run scan path behind POST /monitors/{id}/test’s fetch mode never screens, and never collects a header’s bloom at all, so a test fetch reports the same logs a live window would extract from the same block. Disable bloom_screen for endpoints whose blooms are not trusted, where that traded dependency is not worth accepting. Each skip is counted at fetch time, independent of whether the window later survives reorg verification, as blockwatcher_evm_bloom_skips_total; see Observability.

Trusting a header’s bloom to prove absence has one failure mode: an endpoint or caching proxy whose bloom does not actually describe its own block. bloom.rs‘s admits_log closes that gap using an observation a source that trusts its blooms already pays for and needs no extra call to check: a protocol-conforming bloom is a superset of its block’s logs, so scan.rs collects one bloom per header for any window fetched with bloom_screen: true, whether or not the screen itself found anything in that window to refute, and a window that is not screened — an admitting header, a broad filter, or a filter the screen could not parse — still holds both halves of the proof, the logs themselves and the same blocks’ blooms, fetched moments apart. A window whose source does not trust blooms at all (bloom_screen: false) collects none, so it carries no such proof either way; nothing here checks it, and nothing here vouches for it as clean. After eth_getLogs returns on a window that did collect blooms, scan.rs checks every returned log’s own address and topic0 against its own block’s own bloom. A log with no topics is judged on its address alone, since an anonymous event carries no topic0 for a bloom to have admitted in the first place. Because a bloom filter never produces a false negative for an item it actually holds, a bloom that fails to admit a log the same endpoint just returned can only mean that endpoint’s bloom does not describe the block it claims to belong to: there is no way for this check to fire on a healthy endpoint.

The response trades the optimisation away rather than keep trusting a source that has proven its blooms unreliable: the first contradiction disables bloom_screen on the EvmRpcSource instance that observed it, an AtomicBool tripped with a compare-exchange so the accompanying warning logs exactly once regardless of how many leaves of a split carried the contradiction, naming the endpoint, the first contradicting block, and how many logs contradicted. The latch is monotonic within that instance’s own lifetime; nothing re-enables it short of building a new instance.

Building a new instance is exactly what a pipeline restart does, and restarts are routine: the supervisor restarting an exited source, an Invalidated return after a proven reorg, or a monitor change that escalates to a restart all call the evm-rpc module factory again, which calls EvmRpcSource::new and starts the flag at false. The latch does not, and cannot, remember across that rebuild that this endpoint already proved its blooms wrong; a rebuilt instance resumes screening against the same endpoint from a clean slate. A process-wide memory keyed by endpoint name would close that gap, but it is a different design with its own open questions (an endpoint shared across networks would need a shared verdict, and nothing would evict a stale one), so it is not what this latch does. The durable answer available today is the operator’s own setting: once an endpoint has proven its blooms unreliable, set bloom_screen = false for that network so a later restart does not have to relearn the same fact the hard way. Every contradicting log is still counted, latched or not, as blockwatcher_evm_bloom_contradictions_total, labeled pipeline and the endpoint that served the window; see Observability for how to size the damage already done before the trip, using blockwatcher_evm_bloom_skips_total for the same pipeline.

Disable bloom_screen outright for a chain whose nodes are known to emit zeroed or absent blooms in a way that never contradicts a returned log: a header without a readable bloom is never screened regardless of the setting, and never trips the latch, so that case is not this detector’s to catch.

evm-mempool: acquiring pending transactions

The files under source/mempool/, plus the crate’s shared WebSocket wire client (ws.rs, crate-private, used only from here):

File-by-file map: source/mempool/
FileOwns
config.rsEvmMempoolConfig: ws_url_secret, endpoints (for the eth_getTransactionByHash hydration lookups a subscription hash alone cannot avoid), reconnect_ms (default 1_000), idle_policy (IdlePolicyDef { ping_after_ms: 30_000, pong_deadline_ms: 10_000 }, the wire form of ws::IdlePolicy) (config.rs). There is deliberately no start_block and no confirmation depth: a pending stream has no history to begin from and no reorg barrier to honor (config.rs).
pending.rsPendingHashStream/subscribe_pending: the typed layer over ws.rs that names newPendingTransactions and parses each notification down to the hash it carries, nothing more (pending.rs).
run.rsEvmMempoolSource and its Source::run loop: subscribe, then per hash, check whether any monitor on this pipeline watches functions at all before spending a hydration lookup, with no function interest published, the source skips the eth_getTransactionByHash round trip entirely (run.rs), and forward what comes back, tracking a per-run arrival counter as the cursor (run.rs).

The arrival counter resumes from one past whatever the last persisted checkpoint recorded, never from zero on a warm restart:

#![allow(unused)]
fn main() {
let mut arrival = match ctx.checkpoint.as_ref() {
    Some(cp) => cp.cursor.primary.saturating_add(1),
    None => 0,
};
}

(run.rs:204-207). But since nothing pending during a restart gap is ever recovered, that continuation only prevents a within-run number from repeating; it does not, and cannot, make the counter a replayable position across the gap itself. Selectors § The position problem covers exactly what that costs a consumer.

Endpoints, and the env:/url_secret indirection

source::endpoint::EndpointDef (source/endpoint.rs) is the one endpoint shape both sources ask an operator for and that the shared pool builder behind both factories reads: name, url_secret: String, priority (default High), and an optional rate_limit. url_secret never carries a literal URL: a provider’s URL routinely is the credential (an API key in the path or host), so the field holds an env:NAME reference instead, and the reference resolves in two steps, not inside this struct:

  1. Form validation, at config-validate time. EvmRpcConfig::validate (body: source/rpc/config.rs, its SecretRef::parse call over each endpoint’s url_secret at config.rs) and EvmMempoolConfig::validate (body: source/mempool/config.rs, its SecretRef::parse call over ws_url_secret at config.rs, and over each endpoint’s url_secret at config.rs) each reject a string that is not an env:NAME reference. Neither method checks whether the reference actually resolves: that needs the environment, and validation is a pure predicate over configuration.
  2. Resolution, once, at construction. registry.rs’s resolve_endpoint_url/resolve_ws_url call SecretRef::parse(url_secret)?.resolve()?, then check the result parses as a URL with the scheme this source can actually dial (http/https for evm-rpc, ws/wss for evm-mempool): a scheme check, not just a parse, since Url::parse alone would accept an env: reference typo’d into the variable itself just as happily as a real URL (registry.rs). This runs once, before a single EvmEndpoint or Pool is built; no message anywhere in this path ever carries the resolved value.

blockwatcher_types::SecretRef is where the env: prefix itself is parsed and where the environment variable is actually read (crates/blockwatcher-types/src/secret.rs). This crate calls it but does not reimplement it, and blockwatcher-rpc never sees a url_secret or an env: string at all: by the time an EndpointConfig reaches that crate’s Pool, the secret has already been resolved into whichever connection value this crate’s own EvmEndpoint carries.

The decoder

EvmDecoder (decoder/mod.rs) implements every method of Decoder (crates/blockwatcher-ports/src/decoder.rs):

MethodWhat it does here
chainReturns ChainKind::new("evm") (decoder/mod.rs)
compile_specParses spec.payload as alloy_json_abi::JsonAbi; rejects an anonymous event (no topic0 to ever key on), a selector collision between two declarations, and an ABI with zero events and zero functions; builds SpecInner { events: HashMap<B256, EventPlan>, functions: HashMap<[u8;4], FunctionPlan> } (decoder/mod.rs)
compileDelegates to selector::compile (below) (decoder/mod.rs)
decodeDelegates to decode::decode (decoder/mod.rs)
interestDelegates to selector::interest (decoder/mod.rs)
merge_interestUnions addresses/signatures like the port’s own default, but additionally: blanks the merged address set the moment any input selector watches every address (an empty addresses means “any address,” and a plain union would narrow a source’s fetch below what one monitor asked for), and unions every input’s EvmInterest.function_selectors into the merged chain_specific (decoder/mod.rs)

Files behind compile_spec split write-time work from the per-event/per-function hot-path plan it produces:

  • abi_types.rs: pure naming and typing rules shared by the schema side and the plan side, so a dotted or positional field name can never drift between what a predicate compiles against and what decode actually emits: positional_name/component_name decide field-name spelling, value_type maps one ABI type string (recursing through array dimensions) to blockwatcher_types::ValueType, collapsing any tuple to ValueType::Map (abi_types.rs).
  • compile.rs: ABI fragment to EventSchema, run once per declared event or function: event_schema/function_schema flatten every parameter, rejecting a duplicate parameter name and a duplicate tuple component name at every nesting depth (compile.rs). This is also where the tx/block/log namespaces every compiled EVM spec carries come from: namespaces() (compile.rs) declares the union of what the log path and the function-call path can each fill in, which is the source Selectors § What each kind decodes documents from the predicate-author’s side.
  • plan.rs: the decode-hot-path plan itself, precomputed once per event or function at spec-compile time and never rebuilt: build/build_function resolve every ABI type string into an alloy_dyn_abi DynSolEvent/DynSolCall up front, and pair each top-level parameter with the exact slot decode will read it from (Source::Indexed(n) or Source::Body(n)) and a NamePlan describing how to reassemble its container shape (plan.rs). decode.rs walks this plan and never re-parses a type string on the hot path: that is the entire reason this file exists apart from compile.rs.

decode.rs (decoder/decode.rs) is the hot path proper: Envelope/TxEnvelope parse a log’s or a transaction’s fixed field set once per call (decode.rs), the dispatch described in From an RPC endpoint to a canonical value above routes to decode_log or decode_call, and to_value performs the actual ABI-value-to-canonical-Value conversion against the precomputed plan. DecodeOutcome (the port’s own type) distinguishes two different non-events precisely: an envelope or ABI-encoded payload this decoder cannot make sense of at all is undecodable (counted, surfaced to operators); a well-formed occurrence whose address/topic0 or four-byte selector no compiled entry named is no_match, not a failure, since most logs on a filtered feed and most transactions in a block fall outside any one monitor (decode.rs).

Selector implementation

Selectors documents the compile-time presence rule (events/functions/addresses, and what “naming neither” means) and what each kind hands a predicate in full; this section is only the structural shape underneath it. The dispatch table selector::compile (decoder/selector.rs) builds against is, exactly (decoder/selector.rs:13-25):

#![allow(unused)]
fn main() {
/// One selector entry, self-contained: its `addresses` restrict only the
/// events and functions it names, never a sibling entry's.
pub(crate) struct Entry {
    /// `None` means any address.
    pub addresses: Option<BTreeSet<[u8; 20]>>,
    pub events: HashMap<B256, EventPlan>,
    pub functions: HashMap<[u8; 4], FunctionPlan>,
}

/// Every entry a monitor's selectors compiled to. Entries are OR'd.
pub(crate) struct SelectorInner {
    pub entries: Vec<Entry>,
}
}

events is keyed by the topic0 a log’s first topic carries; functions is keyed by the 4-byte selector a transaction’s calldata leads with. Each Entry is self-contained (its own addresses restrict only its own two tables, never a sibling entry’s), and SelectorInner.entries is the full OR across every selector a monitor names. selector::interest (decoder/selector.rs) derives the fetch-narrowing InterestSet from this same structure: an entry with no addresses (any address) blanks the merged address hint for the whole selector, which is why EvmDecoder::merge_interest above has to repeat that same rule one level up, across selectors rather than within one. Both Entry and SelectorInner live behind CompiledSelector’s type erasure; nothing outside this crate ever names them directly.

evm-mempool compiles selectors exactly the same way evm-rpc does (selection is checked against the spec, not against the source that will feed it), but structurally can only ever populate the functions half of any entry’s dispatch table, since this source never produces a payload decode would route down the log path at all. An events-only selector on an evm-mempool network therefore compiles cleanly and simply never fires; Selectors § Comparison table is the definitive statement of that outcome per selector kind and source.

The registry

Three ModuleRegistry implementations, one per module this crate ships, each declaring its own const NAME and factory() next to its own code (registry.rs):

RegistryNAMEFactory builds
EvmRpcRegistry"evm-rpc"Deserializes EvmRpcConfig, validates it, resolves every endpoint into a Pool<EvmEndpoint>, and constructs EvmRpcSource::new(pool, config) (registry.rs)
EvmMempoolRegistry"evm-mempool"Deserializes EvmMempoolConfig, validates it, resolves the subscription URL and the hydration endpoints, and constructs EvmMempoolSource::new(pool, ws_url, config) (registry.rs)
EvmDecoderRegistry"evm"Deserializes an empty, deny_unknown_fields config and returns Arc::new(EvmDecoder) (registry.rs)

sources::get_all() and decoders::get_all() (registry.rs, both re-exported at the crate root as sources/decoders) fold the three into the two lookup tables a composition root’s catalog needs. blockwatcher-embed‘s build_catalog (crates/blockwatcher-embed/src/catalog.rs) consumes both: it loops each enumeration and calls catalog.register_source/register_decoder, which rejects a duplicate name: the same family!-macro-generated contract (crates/blockwatcher-core/src/catalog.rs) every other module family goes through. build_pool (registry.rs) is the one function both EvmRpcRegistry and EvmMempoolRegistry funnel through, so the two sources’ retry, rate-limit, and timeout behavior can never independently drift: the same “shared builder” pattern blockwatcher-core’s own catalog.rs doc comment describes for module registration in general.

Neighbours

blockwatcher-evm depends on, in production:

  • blockwatcher-types: vocabulary crate (RawEvent, Value, ChainKind, SecretRef, and every id type)
  • blockwatcher-ports: the Source/Decoder port traits this crate implements
  • blockwatcher-rpc: the endpoint pool both sources parameterize with EvmEndpoint
  • async-trait: required to implement the async fn-bearing Source trait
  • alloy-primitives: Address/B256/hex helpers
  • alloy-json-abi: parsing a spec’s Solidity JSON ABI
  • alloy-dyn-abi: resolving ABI type strings and decoding calldata/log data against them
  • num-bigint: the arbitrary-precision integers a canonical Value::Int/Uint holds
  • reqwest: the HTTP client behind every EvmEndpoint
  • url: parsing and validating a resolved endpoint URL’s scheme
  • serde: (de)serialization derive for every config
  • serde_json: the raw JSON payload shape both sources emit and the decoder consumes
  • thiserror: the derive behind EvmRpcError
  • tokio (sync/time/macros/rt features): the async runtime both sources’ run loops execute on
  • tokio-util: cancellation plumbing shared with the rest of the workspace
  • tokio-tungstenite: the WebSocket transport ws.rs wraps
  • futures: stream/sink combinators ws.rs uses
  • tracing: this family’s structured logging
  • metrics: the blockwatcher_evm_mempool_skips_total-style counters this family emits

and, in [dev-dependencies] only:

  • blockwatcher-evm-testkit: a scripted mock JSON-RPC/WebSocket node (MockNode, MockWsNode) and a SimChain fixture builder this crate’s own tests drive; the dependency cycle back to blockwatcher-evm is a dev-only edge cargo permits
  • blockwatcher-ports (fakes feature): fakes for cross-checking against the port contract
  • blockwatcher-testkit: shared test scaffolding

The following crates depend on it directly (per the dependency table):

  • blockwatcher-evm-testkit: reaches back into this crate for real types its mock node fixtures build against (a dev-only cycle, never a production edge)
  • blockwatcher-embed: the composition façade registers both sources and the decoder into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: four pub mod declarations (decoder, jsonrpc, registry, source), two private ones (interest, ws), and the crate-root re-exports (EvmInterest, sources, decoders).
  2. source/endpoint.rs: the config-plane vocabulary shared by both sources, and its doc comment on why url_secret resolves once at construction rather than per call, unlike the webhook sink’s own per-delivery resolution.
  3. jsonrpc.rs: EvmEndpoint, EvmRpcError, and its Classify impl’s documented mapping table; read this before either source, since both build on it.
  4. source/rpc/config.rs, then chain.rs, scan.rs, emit.rs, resume.rs, run.rs in that order: each file’s own doc comment states what it owns and why it sits apart from its neighbours.
  5. source/mempool/config.rs, then pending.rs, run.rs: read run.rs’s module doc comment first; it states every contract difference from evm-rpc in one place.
  6. decoder/abi_types.rs, then compile.rs, plan.rs: the three write-time files, in the order the module doc comments cross-reference each other.
  7. decoder/selector.rs, then decoder/decode.rs: compile-time selector dispatch, then the hot path that reads it.
  8. decoder/mod.rs: EvmDecoder’s full Decoder impl, last, once every piece it delegates to is familiar.
  9. registry.rs: the three ModuleRegistry impls, build_pool, and resolve_endpoint_url/resolve_ws_url; its own module doc comment states the registration convention every module family in the workspace follows.