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-evmis the EVM chain family: two sources,evm-rpc(confirmed blocks, misses nothing) andevm-mempool(pending transactions, no guarantee of mining), plus the oneevmdecoder 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’snewPendingTransactionsfeed 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 theevmdecoder under the single registration convention every module family follows, and resolve each endpoint’surl_secretinto a value neither this crate norblockwatcher-rpcever 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
| Name | Kind | Role |
|---|---|---|
EvmDecoder | struct | The EVM Decoder port implementation (decoder/mod.rs) |
source::rpc::EvmRpcSource | struct | The evm-rpc Source port implementation (source/rpc/run.rs) |
source::mempool::EvmMempoolSource | struct | The evm-mempool Source port implementation (source/mempool/run.rs) |
EvmInterest | struct | The EVM-typed extension of InterestSet::chain_specific: the exact 4-byte function selectors some monitor wants (interest.rs) |
jsonrpc::EvmEndpoint | struct | One 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::EvmRpcError | enum | Every provider failure this family crosses a boundary as; implements Classify for blockwatcher-rpc’s retry policy (jsonrpc.rs) |
source::endpoint::EndpointDef | struct | One endpoint’s operator-facing config: name, url_secret, priority, optional rate limit, shared by both sources (source/endpoint.rs) |
source::endpoint::EndpointPriority | enum | High/Low selection tier, the wire form of blockwatcher_rpc::Priority (source/endpoint.rs) |
source::rpc::config::EvmRpcConfig | struct | evm-rpc’s boot-time config: endpoints, start_block, confirmations, lag tolerance, poll interval, logs window, receipt policy (source/rpc/config.rs) |
source::rpc::config::ReceiptPolicy | enum | Always/WhenRead: when a matching transaction’s receipt is worth fetching for tx.status alone (source/rpc/config.rs) |
source::mempool::config::EvmMempoolConfig | struct | evm-mempool’s boot-time config: subscription URL, hydration endpoints, reconnect interval, and a nested idle_policy (source/mempool/config.rs) |
source::mempool::config::IdlePolicyDef | struct | The 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::RecentChain | struct | Bounded memory of recently seen (block, hash) pairs, used to verify a fetched window still lines up (source/rpc/chain.rs) |
source::rpc::chain::Header, LinkageBreak | struct | One 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, EvmDecoderRegistry | struct | The three ModuleRegistry implementations this crate registers (registry.rs) |
registry::sources::get_all, registry::decoders::get_all (sources/decoders at crate root) | fn | Family 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/
| File | Owns |
|---|---|
config.rs | EvmRpcConfig: 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.rs | RetryBackoff, 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.rs | RecentChain, 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.rs | LogFilter/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.rs | Turns 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.rs | EvmRpcSource::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.rs | EvmRpcSource 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(default4): how manyeth_getTransactionReceiptcalls 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 sameDegraded-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(default20): how manyeth_getBlockByNumberrequests ride one JSON-RPC batch (scan.rs). A window ofwblocks costsceil(w / header_batch)header round trips rather thanwof 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 wideningfull_block_windowstops 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/
| File | Owns |
|---|---|
config.rs | EvmMempoolConfig: 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.rs | PendingHashStream/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.rs | EvmMempoolSource 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:
- Form validation, at config-validate time.
EvmRpcConfig::validate(body:source/rpc/config.rs, itsSecretRef::parsecall over each endpoint’surl_secretatconfig.rs) andEvmMempoolConfig::validate(body:source/mempool/config.rs, itsSecretRef::parsecall overws_url_secretatconfig.rs, and over each endpoint’surl_secretatconfig.rs) each reject a string that is not anenv:NAMEreference. Neither method checks whether the reference actually resolves: that needs the environment, and validation is a pure predicate over configuration. - Resolution, once, at construction.
registry.rs’sresolve_endpoint_url/resolve_ws_urlcallSecretRef::parse(url_secret)?.resolve()?, then check the result parses as a URL with the scheme this source can actually dial (http/httpsforevm-rpc,ws/wssforevm-mempool): a scheme check, not just a parse, sinceUrl::parsealone would accept anenv:reference typo’d into the variable itself just as happily as a real URL (registry.rs). This runs once, before a singleEvmEndpointorPoolis 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):
| Method | What it does here |
|---|---|
chain | Returns ChainKind::new("evm") (decoder/mod.rs) |
compile_spec | Parses 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) |
compile | Delegates to selector::compile (below) (decoder/mod.rs) |
decode | Delegates to decode::decode (decoder/mod.rs) |
interest | Delegates to selector::interest (decoder/mod.rs) |
merge_interest | Unions 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 whatdecodeactually emits:positional_name/component_namedecide field-name spelling,value_typemaps one ABI type string (recursing through array dimensions) toblockwatcher_types::ValueType, collapsing anytupletoValueType::Map(abi_types.rs).compile.rs: ABI fragment toEventSchema, run once per declared event or function:event_schema/function_schemaflatten every parameter, rejecting a duplicate parameter name and a duplicate tuple component name at every nesting depth (compile.rs). This is also where thetx/block/lognamespaces 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_functionresolve every ABI type string into analloy_dyn_abiDynSolEvent/DynSolCallup front, and pair each top-level parameter with the exact slotdecodewill read it from (Source::Indexed(n)orSource::Body(n)) and aNamePlandescribing how to reassemble its container shape (plan.rs).decode.rswalks this plan and never re-parses a type string on the hot path: that is the entire reason this file exists apart fromcompile.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):
| Registry | NAME | Factory 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: theSource/Decoderport traits this crate implementsblockwatcher-rpc: the endpoint pool both sources parameterize withEvmEndpointasync-trait: required to implement theasync fn-bearingSourcetraitalloy-primitives:Address/B256/hex helpersalloy-json-abi: parsing a spec’s Solidity JSON ABIalloy-dyn-abi: resolving ABI type strings and decoding calldata/log data against themnum-bigint: the arbitrary-precision integers a canonicalValue::Int/Uintholdsreqwest: the HTTP client behind everyEvmEndpointurl: parsing and validating a resolved endpoint URL’s schemeserde: (de)serialization derive for every configserde_json: the raw JSON payload shape both sources emit and the decoder consumesthiserror: the derive behindEvmRpcErrortokio(sync/time/macros/rtfeatures): the async runtime both sources’ run loops execute ontokio-util: cancellation plumbing shared with the rest of the workspacetokio-tungstenite: the WebSocket transportws.rswrapsfutures: stream/sink combinatorsws.rsusestracing: this family’s structured loggingmetrics: theblockwatcher_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 aSimChainfixture builder this crate’s own tests drive; the dependency cycle back toblockwatcher-evmis a dev-only edge cargo permitsblockwatcher-ports(fakesfeature): fakes for cross-checking against the port contractblockwatcher-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; theblockwatcherbinary reaches this crate only through embed
Reading the source
- Start at
lib.rs: fourpub moddeclarations (decoder,jsonrpc,registry,source), two private ones (interest,ws), and the crate-root re-exports (EvmInterest,sources,decoders). source/endpoint.rs: the config-plane vocabulary shared by both sources, and its doc comment on whyurl_secretresolves once at construction rather than per call, unlike thewebhooksink’s own per-delivery resolution.jsonrpc.rs:EvmEndpoint,EvmRpcError, and itsClassifyimpl’s documented mapping table; read this before either source, since both build on it.source/rpc/config.rs, thenchain.rs,scan.rs,emit.rs,resume.rs,run.rsin that order: each file’s own doc comment states what it owns and why it sits apart from its neighbours.source/mempool/config.rs, thenpending.rs,run.rs: readrun.rs’s module doc comment first; it states every contract difference fromevm-rpcin one place.decoder/abi_types.rs, thencompile.rs,plan.rs: the three write-time files, in the order the module doc comments cross-reference each other.decoder/selector.rs, thendecoder/decode.rs: compile-time selector dispatch, then the hot path that reads it.decoder/mod.rs:EvmDecoder’s fullDecoderimpl, last, once every piece it delegates to is familiar.registry.rs: the threeModuleRegistryimpls,build_pool, andresolve_endpoint_url/resolve_ws_url; its own module doc comment states the registration convention every module family in the workspace follows.