EVM
The EVM family is three sources,
evm-rpc, evm-mempool, and evm-subgraph, and the evm
decoder all three feed
(crates/blockwatcher-evm). An operator selects one source per
network; they are never
combined in one pipeline.
The crate page maps the same
facts onto file layout. This page is the operator surface: modules,
selector keys, spec payload, and what the evm feature folds.
Key takeaways
- Selector keys are
eventsandfunctions. Naming neither selects every event and function the referenced spec declares; naming either selects only what it names. eventsmatches a decoded log;functionsmatches decoded calldata. Both kinds compile the same way regardless of source; what differs is the raw material a source ever supplies.evm-rpccan satisfy both kinds in one poll cycle.evm-subgraphcan too, for whatever the deployed subgraph already indexed: interest filters only narrow GraphQL_infetching, never matching, so a monitor for an address the mapping never stored never fires.evm-mempoolonly ever producesfunctions, and only unmined fields. Its cursor is a per-run arrival counter, so it sits outside at-least-once delivery.- A spec payload is a Solidity JSON ABI. Network
source.configis the family’s RPC, mempool, or subgraph shape, not a shared schema.
Modules
| Family | Module | Registered in | Trade-off |
|---|---|---|---|
| source | evm-rpc | crates/blockwatcher-evm/src/registry.rs | Waits for an event to be mined and positioned before reporting it, which is exactly what lets at-least-once delivery work; the trade is RPC calls spent polling and the wait for confirmation. Reports a confirmed tip and cold-starts from start_block. |
| source | evm-mempool | crates/blockwatcher-evm/src/registry.rs | Reports a transaction the moment it’s pending, ahead of mining and with no guarantee it’s ever mined; because its cursor is just an arrival counter that a restart can’t recover, at-least-once delivery guarantees don’t extend to it. Reports neither a confirmed tip nor a cold start. |
| source | evm-subgraph | crates/blockwatcher-evm/src/registry.rs | Polls a documented GraphQL logs schema and emits the same confirmed-block envelopes evm-rpc does, on the same kinded cursor packing; completeness is whatever that subgraph indexed, not the chain, which is the cost of a handful of HTTP round trips over an already-built index. Reports a confirmed tip and cold-starts from start_block. |
| decoder | evm | crates/blockwatcher-evm/src/registry.rs | Compiles a Solidity ABI into reusable schemas once per spec write; chain-specific decode logic for EVM lives in this crate, not in the core ring. |
The evm Cargo feature on blockwatcher-embed and the blockwatcher
binary folds those four modules. Turning it off removes them from
build_catalog; turning it off leaves every other family’s modules in
place.
evm-rpc and evm-subgraph both declare
SourceCaps { event_timestamps: true }: every occurrence they emit
carries a mined block’s timestamp, so a time-window gate
can compile against either. evm-mempool declares false: a pending
transaction has not been included in a block, so a time-window gate
cannot compile against a network that uses it.
evm-rpc and evm-subgraph report a confirmed tip and cold-start from
start_block. evm-mempool reports neither.
Spec payload
A spec whose chain is evm carries a Solidity JSON ABI array as
payload. The write runs EvmDecoder::compile_spec
(crates/blockwatcher-evm/src/decoder/mod.rs). Worked JSON lives on
Spec and Your first
monitor.
Selector keys
A selector body carries optional keys events, functions, and
addresses, and nothing else; an unrecognized key is rejected by name in
compile (crates/blockwatcher-evm/src/decoder/selector.rs). What
actually gets selected turns on one boolean the compiler computes once per
entry, select_all, which is true exactly when neither events nor
functions appears in the body at all (selector.rs). With
select_all false, each key resolves independently against whatever names
it carries: writing only events: ["Transfer"] builds a dispatch table
with that one event and zero functions; it never falls back to “every
function” for the side you didn’t mention. With select_all true, both
tables are filled from the entire vocabulary the spec exposes: nothing
past that one spec’s boundary, so two specs on the same chain never bleed
into each other’s “select everything,” which is why adding a new
declaration to a spec silently widens an existing catch-all monitor’s reach
the moment the deployment next recompiles it, without anyone touching the
monitor. One spelling is refused outright rather than given
either meaning: a key present but pointed at an empty array, rejected in
name_list (selector.rs).
Naming an event or function the referenced spec doesn’t declare rejects with
a suggestion from kind_suggestion: the spec’s first declared name of that
kind, not an edit-distance match (selector.rs; see Resources
for why that differs from a predicate’s unknown-field suggestion). events
decodes against a log’s topic0; functions decodes against a transaction’s
first four bytes of calldata: two independent dispatch tables on the
Entry struct inside one compiled selector (selector.rs), which is what
lets one selector entry watch both at once.
Monitor shows a complete resource
that uses these keys. An address must be a 0x-prefixed 40-hex-digit
string. A mixed-case address claims an EIP-55 checksum and is held to it;
an all-lowercase or all-uppercase spelling makes no checksum claim.
Shared selector contracts (spec reference, OR across entries) stay on
Selectors.
What each kind decodes
events matches a decoded log. Its data reaches a predicate as:
args.*: the event’s own declared parameters.tx.hash,tx.index: always present; every log carries its transaction’s envelope.tx.status: always present and always1: a log exists only inside a transaction that succeeded, so this is a constant stamped at decode time byassemble_fields, never a receipt fetch (crates/blockwatcher-evm/src/decoder/decode.rs).tx.from,tx.to,tx.value: never present on a log-decoded occurrence; nothing in a log’s own envelope carries them, and fetching them would mean a receipt/transaction lookup the log-only path is designed to avoid, as documented onnamespaces(crates/blockwatcher-evm/src/decoder/compile.rs).block.number,block.hash,block.timestamp,log.address,log.index: always present; every log’s own envelope carries all five.
functions matches a decoded transaction’s calldata. Its data reaches a
predicate as:
args.*: the function’s own declared parameters, decoded regardless of whether the call reverted: calldata decodes independent of the receipt.tx.hash,tx.from,tx.value: always present; every transaction carries them by definition.tx.to: present unless the transaction is a contract creation, which carries notoat all.tx.index,tx.status, and everyblock.*field: present only once the transaction is mined. A pending transaction has none of them yet, since they areTxEnvelope’s optional fields, assembled byassemble_call_fields(crates/blockwatcher-evm/src/decoder/decode.rs), and a predicate reading one before that resolvesUnknown, never an error, never a fabricated value.- No
log.*namespace at all: nothing about a function-call decode came from a log, as documented onassemble_call_fields(decode.rs).
Because a log only exists when its transaction succeeded, an events
selector has no way to notice a revert at all: the occurrence it would
decode is never emitted in the first place. A functions selector sees the
call regardless of how it ended, since decoding calldata never touches the
receipt; only its tx.status field carries the outcome, and only once the
transaction is mined. Watching for failed calls is therefore a functions
concern exclusively.
flowchart TD
start{"selector kind"} -->|"events"| ev{"source?"}
start -->|"functions"| fnq{"source?"}
ev -->|"evm-rpc"| evlog["decodes a log<br/>full args, tx, block, log"]
ev -->|"evm-subgraph"| evsub["decodes a log the subgraph indexed<br/>same envelope as evm-rpc"]
ev -->|"evm-mempool"| evnone["no-op: never fires,<br/>this source has no logs"]
fnq -->|"evm-rpc"| fnmined["decodes a mined tx<br/>tx/block fields once mined"]
fnq -->|"evm-subgraph"| fnsub["decodes a mined tx the subgraph indexed<br/>same envelope as evm-rpc"]
fnq -->|"evm-mempool"| fnpending["decodes a pending tx<br/>no tx.status, no block.*"]
evlog --> pred["predicate evaluates"]
evsub --> pred
fnmined --> pred
fnsub --> pred
fnpending --> pred
Sources
Both selector kinds compile the same way regardless of source. What differs
is what a given source ever calls decode with, and decode itself
dispatches purely on the shape of that payload: an object carrying
topics is a log, one carrying input (and no topics) is a transaction,
and nothing ever carries both (crates/blockwatcher-evm/src/decoder/decode.rs).
evm-rpc scans confirmed blocks: it fetches full block bodies (headers
plus every transaction) and eth_getLogs results in the same poll cycle, so
one network running this source can satisfy both selector kinds at once:
whatever a selector’s events half names comes from that cycle’s logs,
whatever its functions half names comes from that cycle’s mined
transactions, carried on one non-decreasing cursor stream, packed by
pack_secondary and emitted by emit_verified_leaf, that always walks a
block’s transaction list to completion before touching that same
block’s logs (crates/blockwatcher-evm/src/source/rpc/emit.rs). A
selector written with only one of the two keys simply gets fed from only the
matching half of that cycle. Whether the source bothers fetching full
transaction bodies at all is itself interest-driven: a pipeline with no
monitor watching any functions selector never asks for them, in
scan_range (crates/blockwatcher-evm/src/source/rpc/emit.rs, mirrored in the
streaming path).
Cursor: primary is block number; secondary packs (kind << 32) | index so
a block’s transactions order ahead of its logs
(crates/blockwatcher-types/src/cursor.rs is compared, never interpreted,
by core). journal_depth is denominated in those primary units.
evm-subgraph polls a hosted GraphQL logs schema (logs /
transactions / _meta) and emits the same 0x decoder envelopes
evm-rpc does, on the same kinded cursor packing: transactions then
logs, pack_secondary identical
(crates/blockwatcher-evm/src/source/cursor.rs). Completeness is whatever
the deployed subgraph’s mapping already stored. InterestSet only
narrows GraphQL _in filters (address_in / topic0_in / to_in /
selector_in); empty sets omit the key rather than sending [],
because some hosts treat an empty _in as match-nothing. Interest never
changes matching: a monitor for an address the subgraph never indexed
never fires, and a stored log outside the current interest is still
fetched when interest is empty. Transactions are queried only while some
monitor watches functions (EvmInterest). confirmations defaults to
0: the head this source reads from _meta is already the subgraph’s
indexed tip, trailing the chain by the mapping’s own delay, so a second
wait on top is usually pure latency.
A schema that cannot answer logs, transactions, or _meta
(Cannot query field) is a named Degraded, not an empty successful
window. Hash disagreement against a height this run already sent through
ctx.events returns SourceOutcome::Invalidated { from } with rpc’s
end-of-block secondary (the last still-good height). In-window
disagreement against unsent work retries in place. The tracker is
(number, hash) only — a GraphQL Log has no parentHash, so this is
not rpc RecentChain.
evm-mempool watches a single node’s stream of
not-yet-mined transactions and never sees a log in its entire lifetime:
mining is the event that produces a receipt, and a log lives inside one, so
an occurrence this source hands the decoder is calldata or nothing. That
means the only payload shape it ever produces is one decode routes down
the functions path, as documented on EvmMempoolSource
(crates/blockwatcher-evm/src/source/mempool/run.rs).
A selector’s events half compiles cleanly against this source’s network
too (selection is checked against the spec, not the source that will feed
it), but can never contribute a single match, because there is nothing here
for it to decode. Only the functions half of any selector on such a
network ever does anything, and if the spec behind it has no functions in
it to begin with, that selector on this source is a complete no-op, matching
nothing ever. The source itself checks whether any monitor on its pipeline
watches functions before paying for a lookup; with no function interest
published, it skips the eth_getTransactionByHash round trip entirely and
forwards nothing, in run (source/mempool/run.rs).
Why evm-mempool cannot make the same promises
Every other part of a compiled selector is source-independent: the same
schema, the same dispatch table, the same predicate. What genuinely differs
between evm-rpc and evm-mempool is what each one’s
cursor is even counting. evm-rpc
answers a question a mined chain can always answer, “where in the chain is
this?”, with a block number and an in-block ordering bit that puts every
transaction ahead of every log it shares a block with. evm-mempool has no
such question to answer: nothing pending has a place in the chain yet, so
its cursor counts something else entirely: how many occurrences this one
process has forwarded since it started, tracked by run’s arrival counter
(crates/blockwatcher-evm/src/source/mempool/run.rs).
This is not a detail that stays contained inside the source. Because a
checkpoint is exactly that cursor plus enough state to verify a resume
(crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs), evm-mempool’s
persisted checkpoint is a dedupe watermark for this one process’s lifetime,
never a replayable position: whatever was pending in the node’s mempool
while the process was down is simply gone on restart, and nothing resumes
it, unlike evm-rpc’s checkpoint, which always names a real block to
continue scanning from. scan and confirmed_tip are therefore
unsupported on this source outright: there is no history to fetch and no
confirmed tip to report (source/mempool/run.rs).
That instability reaches all the way into how a
match is identified. Restart this
source and its arrival counter starts over from wherever the fresh
checkpoint left off, so the identical pending call, seen again after the
gap, is minted a different number and therefore a different match id. There
is no way to prevent this, because nothing about a hash-derived position can
be made to behave monotonically across a process boundary, which is exactly
what the Source port requires of a cursor. The same drift can happen
inside one run, with no restart at all: a transaction the subscription
already announced can get mined while its hydration lookup is still
outstanding, and the copy that comes back then carries a real
transactionIndex it didn’t have a moment before, changing the decoded
fields (and the id derived from them) out from under it, as documented on
EvmMempoolSource (crates/blockwatcher-evm/src/source/mempool/run.rs). The fix lives with
whatever consumes these matches, not with the source itself: read the
transaction’s own hash back out of the delivered payload and deduplicate
on that, since it is the one thing two sightings of the same pending call
are guaranteed to agree on. It’s exactly this instability that keeps
evm-mempool outside the reach of
at-least-once
delivery: every
source whose cursor is a chain position can promise it, this one cannot.
| fires on | data available to a predicate | position / delivery guarantee | |
|---|---|---|---|
events (any source) | a decoded log | args.*; tx.hash/index/status(=1); no tx.from/to/value; full block.*; full log.* | inherits whichever source produced the log |
functions on evm-rpc | a decoded transaction, mined | args.*; tx.hash/from/value always, tx.to unless a creation, tx.index/tx.status/block.* once mined; no log.* | chain position; at-least-once |
events / functions on evm-subgraph | a decoded log or mined transaction the subgraph indexed | same fields as the evm-rpc rows above | chain position; at-least-once; completeness is the indexed set, not the chain |
functions on evm-mempool | a decoded pending transaction | same fields as above; tx.status and block.* are always absent: this source never fetches a receipt and never injects a block timestamp, so block.* can never fully assemble regardless of mining. tx.index is usually absent too, but not always: it comes straight off the same lookup response, and a call mined between notification and hydration comes back with a real one | arrival counter only; not replayable across a restart; dedupe by tx.hash, not match id |
events on evm-mempool | nothing | none | this source never produces a log |
Delivery guarantees states the engine-side consequence: a source whose cursor is not a resumable chain position cannot make at-least-once. This page is that source.
Source config
The network resource envelope (id, chain, source.module) is on Network. The tables below are this family’s source.config.
Endpoints: the shared pool vocabulary
evm-rpc and evm-mempool name a pool of HTTP JSON-RPC endpoints with the
same shape (EndpointDef, crates/blockwatcher-evm/src/source/endpoint.rs).
evm-subgraph uses a sibling row (SubgraphEndpointDef,
source/subgraph/config.rs) that adds header_secrets rather than
grafting that field onto EndpointDef. Each JSON-RPC endpoint object:
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | none (required) | Labels every metric and log line for this endpoint. A repeat refuses: endpoint name 'primary' is used by more than one endpoint; endpoint names must be unique. |
url_secret | string | none (required) | An env:NAME reference to where the URL lives, never the URL itself. A value that is not a reference refuses with endpoint '<name>' url_secret is not a secret reference: ..., deliberately never echoing what was written: a provider URL routinely carries an API key. |
priority | string: high or low | "high" | The selection tier; high endpoints are always tried before low. |
rate_limit | object { "rps": u32 } | none | A preemptive request rate enforced before a call leaves the pool. A zero refuses: endpoint '<name>' rate_limit.rps is 0; it must be at least 1. |
weight | u32 | 1 | Ring slots in the tier’s rotation; meaningful only under round_robin selection, inert under ordered (and therefore always inert on evm-mempool, whose pool is fixed to ordered and exposes no selection knob). A zero refuses: endpoint '<name>' weight is 0; it must be at least 1. Above the pool’s cap of 100 (blockwatcher_rpc::MAX_WEIGHT) refuses: endpoint '<name>' weight (101) exceeds the maximum (100). |
evm-rpc
Polls confirmed blocks over HTTP JSON-RPC: events via eth_getLogs, and,
only while some monitor watches functions, full transaction bodies too. Its
config (EvmRpcConfig, crates/blockwatcher-evm/src/source/rpc/config.rs)
rejects unrecognized fields, including a network key: which network a
source’s events belong to is the engine’s to supply, never the config’s.
The annotated example above is the module’s complete example config. The tunables:
| Key | Type | Default | Meaning |
|---|---|---|---|
endpoints | array | none (required) | The pool, per the table above. An empty array refuses: evm-rpc requires at least one endpoint. |
selection | string: ordered or round_robin | "ordered" | How the pool orders a tier’s endpoints when no window pin dictates the choice: ordered concentrates calls on the first admissible endpoint in configuration order; round_robin spreads windows across the tier, weighted by each endpoint’s weight. A misspelling is refused by serde naming both accepted spellings. |
start_block | u64 | none (required) | Where a run with no persisted checkpoint begins scanning. Deliberately absolute and without a default: a head-relative start would re-derive a different block on every restart and silently skip the gap. |
confirmations | u64 | 12 | How deep a block must age before it is emitted; trades delivery latency for reorg safety. |
max_lag_blocks | u64 | 3 | How far behind the pool’s most current head an endpoint may report before it is excluded from serving. |
poll_interval_ms | u64 | 3000 | How often, while caught up, one eth_blockNumber head check advances the emission barrier. |
logs_window | object | { "initial": 512, "max": 2048 } | How wide an eth_getLogs scan window starts and how far it may grow, per the table below. |
full_block_window | u64 | 8 | Ceiling on a range fetched with full transaction bodies, applied only while some monitor watches functions. A zero refuses: full_block_window (0) must be at least 1. |
probe_interval_ms | u64 | 10000 | How often every non-open endpoint is probed for health changes, bypassing rate limits. |
retry_backoff_max_ms | u64 | 30000 | Cap on the doubling backoff for a window the run loop cannot fetch; the schedule starts at one poll interval. A cap below poll_interval_ms refuses: retry_backoff_max_ms (2999) is below poll_interval_ms (3000); the retry schedule starts at one poll interval, so a smaller cap silently disables the backoff. |
receipts | string: always or when_read | "always" | When a matching transaction spends an eth_getTransactionReceipt, which buys exactly tx.status. always keeps the derived match id a function of chain content alone; when_read saves the round trip when no predicate reads tx.status, at the cost of a payload (and match id) that changes with the monitor set. A misspelling is refused by serde naming both accepted values. |
receipt_concurrency | u64 | 4 | How many eth_getTransactionReceipt calls may be in flight at once for one leaf’s matching transactions. Receipts are unpinned consensus reads, so concurrency changes pacing alone and never consistency; the per-endpoint rate limiter and breaker still gate every call. Raising it shortens function-heavy leaves at the cost of burstier provider load. A zero refuses: receipt_concurrency (0) must be at least 1. |
header_batch | u64 | 20 | How many eth_getBlockByNumber requests ride one JSON-RPC batch, so a window of w blocks costs ceil(w / header_batch) header round trips. A batch is one HTTP request against one endpoint, so it inherits the window pin exactly as single calls do. 1 sends classic single calls, which is the setting for a provider that rejects batch arrays: such a provider fails its first window visibly and the run loop retries it under Degraded. A zero refuses: header_batch (0) must be at least 1. |
bloom_screen | bool | true | Whether a window may skip its eth_getLogs call when every fetched header’s logsBloom proves no monitored address or topic0 can be present. Against a protocol-conforming node the skip is lossless, since a bloom is a superset of its own block’s logs. Enabling it nonetheless adds a dependency the unscreened path does not carry: correctness rests on the bloom as well as on the logs response, so an endpoint or caching proxy that serves correct logs behind a zeroed or otherwise-inaccurate bloom loses those logs silently. Disable it for endpoints whose blooms are not trusted. A broad filter, carrying neither addresses nor topic0s, is never screened, and a header without a readable bloom is never screened regardless of this setting. |
logs_window (LogsWindow, same file):
| Key | Type | Default | Meaning |
|---|---|---|---|
initial | u64 | 512 | Starting scan width. A zero refuses: logs_window.initial (0) must be at least 1. A value above max refuses: logs_window.initial (2048) exceeds logs_window.max (512). |
max | u64 | 2048 | How far the width may grow back after a provider forces it narrower. At runtime the first width a provider refuses drops the run’s effective ceiling pool-wide, so one narrow-limited endpoint caps every endpoint until the source next starts. |
evm-subgraph
Polls a documented GraphQL logs schema over HTTP POST: logs, and only
while some monitor watches functions, transactions, with head from
_meta { block { number hash } }. Its config (EvmSubgraphConfig,
crates/blockwatcher-evm/src/source/subgraph/config.rs) rejects
unrecognized fields, including a network key and every rpc-only knob
(receipts, receipt_concurrency, header_batch, bloom_screen,
full_block_window): which network a source’s events belong to is the
engine’s to supply, and those rpc fields have no GraphQL equivalent.
A worked seed of this module — including the deployable subgraph/
schema and mapping — is Monitoring ERC-20 transfers
[Subgraph].
The module’s complete example config, registry_examples/evm_subgraph.json
from crates/blockwatcher-evm/src/, verbatim, the same file the crate’s
family-completeness test constructs:
{
"start_block": 18000000,
"endpoints": [
{
"name": "goldsky",
"url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_SUBGRAPH_URL",
"priority": "high",
"rate_limit": { "rps": 25 },
"header_secrets": {
"authorization": "env:BLOCKWATCHER_EXAMPLE_EVM_SUBGRAPH_TOKEN"
}
}
]
}
| Key | Type | Default | Meaning |
|---|---|---|---|
endpoints | array | none (required) | The GraphQL pool. An empty array refuses: evm-subgraph requires at least one endpoint. Each row is SubgraphEndpointDef, not EndpointDef: the shared keys (name, url_secret, priority, rate_limit, weight) mean the same as the JSON-RPC table above, plus header_secrets below. |
selection | string: ordered or round_robin | "ordered" | How the pool orders a tier’s endpoints when no window pin dictates the choice, same as evm-rpc. |
start_block | u64 | none (required) | Where a run with no persisted checkpoint begins scanning. Deliberately absolute and without a default: a head-relative start would re-derive a different block on every restart and silently skip the gap. |
confirmations | u64 | 0 | How deep a block must age past the subgraph head before it is emitted. The default is 0 because _meta already trails the chain by the mapping’s indexing delay; raise it on a fast-reorging chain. |
max_lag_blocks | u64 | 3 | How far behind the pool’s most current head an endpoint may report before it is excluded from serving. |
poll_interval_ms | u64 | 3000 | How often, while caught up, one _meta head check advances the emission barrier. |
logs_window | object | { "initial": 512, "max": 2048 } | How wide a GraphQL logs query’s block range starts and how far it may grow, per the table below. Field names match evm-rpc; this width is the from/to on the GraphQL query, not an eth_getLogs range. A host that refuses a width as too expensive (RetryNarrower: complexity, too many, too large, payload, query timeout) halves it and retries the same next block — lossless, no blocks skipped. HTTP 429 is RateLimited and does not shrink the range. |
probe_interval_ms | u64 | 10000 | How often every non-open endpoint is probed for health changes, bypassing rate limits. |
retry_backoff_max_ms | u64 | 30000 | Cap on the doubling backoff for a window the run loop cannot fetch; the schedule starts at one poll interval. A cap below poll_interval_ms refuses: retry_backoff_max_ms (2999) is below poll_interval_ms (3000); the retry schedule starts at one poll interval, so a smaller cap silently disables the backoff. |
entity_page_size | u64 | 1000 | How many entities each logs / transactions page asks for (first). Validate 1..=1000: 0 would fetch nothing, and 1000 is the common hosted-subgraph page cap. |
Each subgraph endpoint may also set:
| Key | Type | Default | Meaning |
|---|---|---|---|
header_secrets | object, header name → env:VAR | {} | Extra request headers whose values are secret references. Always combined with Content-Type: application/json, set at construct. Duplicate names after HTTP-header normalization are refused: endpoint '<name>' header '<key>' is set more than once in 'header_secrets'; remove one — a single header has no defined winner. A value that is not a reference refuses without echoing what was written. Hosted subgraphs (Goldsky, The Graph) typically put authorization here. |
logs_window (LogsWindow in source/subgraph/config.rs, a sibling of
rpc’s type so GraphQL rustdoc does not talk about eth_getLogs):
| Key | Type | Default | Meaning |
|---|---|---|---|
initial | u64 | 512 | Starting block-range width. A zero refuses: logs_window.initial (0) must be at least 1. A value above max refuses: logs_window.initial (2048) exceeds logs_window.max (512). |
max | u64 | 2048 | Operator ceiling: initial must be <= max, and behind > logs_window.max is the CatchingUp threshold. Width only shrinks this run; it does not grow back, and a refused width does not cap every endpoint. |
Host schema
The deployment must implement this contract. A 200 with errors[] naming
Cannot query field for logs, transactions, or _meta publishes
Degraded whose reason starts subgraph schema does not implement Log/Transaction/_meta: — it is not treated as an empty successful
window. Fix the subgraph (or point at one that implements the schema);
changing logs_window cannot create missing fields.
Log: id, address, topic0, topics, data, blockNumber,
blockHash, blockTimestamp, transactionHash, transactionIndex,
logIndex.
Transaction: id, hash, from, to (null on create), input,
selector, value, status, plus the same block fields. Transactions
with input shorter than 4 bytes are not stored.
Head: _meta { block { number hash } }. No Block entity.
id padding: decimal ASCII, 12-digit block, 5-digit txIndex, 5-digit
logIndex. Example: 000018000000-00003-00012. Padded ids make id_gt
lexicographic chain order.
Queries: logs / transactions; address_in / topic0_in /
to_in / selector_in when those InterestSet sets are non-empty; omit
_in when empty; blockNumber_gte / lte; id_gt; first: entity_page_size; orderBy: id, asc. Query transactions only if
EvmInterest is present. The source translates GraphQL Bytes /
BigInt into the decoder’s 0x envelope at this boundary; the decoder
never sees GraphQL types, id, or selector.
evm-mempool
Subscribes to pending transaction hashes over WebSocket and hydrates each
against an HTTP pool: a notification carries a hash and nothing else, so
every candidate costs one eth_getTransactionByHash. Its config
(EvmMempoolConfig, crates/blockwatcher-evm/src/source/mempool/config.rs)
has no start_block and no confirmations: a pending stream has no history
to begin from and no reorg barrier to honor, and a start_block copied over
from an evm-rpc network is refused by name as an unknown field rather than
silently ignored. What that difference means for delivery guarantees is on
Why evm-mempool cannot make the same promises.
The module’s complete example config, registry_examples/evm_mempool.json
from crates/blockwatcher-evm/src/, verbatim, the same file the crate’s
family-completeness test constructs:
{
"ws_url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_MEMPOOL_WS_URL",
"endpoints": [
{
"name": "primary",
"url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL"
}
]
}
| Key | Type | Default | Meaning |
|---|---|---|---|
ws_url_secret | string | none (required) | An env:NAME reference naming where the ws:// or wss:// subscription endpoint lives. A value that is not a reference refuses with ws_url_secret is not a secret reference: ..., never echoing what was written. |
endpoints | array | none (required) | The HTTP hydration pool, per the shared endpoint table above. An empty array refuses: evm-mempool requires at least one endpoint. |
reconnect_ms | u64 | 1000 | How long to wait after a dropped subscription before dialing again; a fixed interval with no jitter and no backoff. A zero refuses: reconnect_ms is 0; it must be at least 1. |
idle_policy | object | { "ping_after_ms": 30000, "pong_deadline_ms": 10000 } | When to suspect the subscription half-open, and how long to wait for proof before redialing, per the table below. |
idle_policy (IdlePolicyDef, same file, the wire form of ws::IdlePolicy):
| Key | Type | Default | Meaning |
|---|---|---|---|
ping_after_ms | u64 | 30000 | How long a subscription may sit silent before an idle WebSocket ping goes out. A zero refuses: idle_policy.ping_after_ms is 0; it must be at least 1. |
pong_deadline_ms | u64 | 10000 | How long to wait for any frame after an idle ping before presuming the connection half-open and redialing. A zero refuses: idle_policy.pong_deadline_ms is 0; it must be at least 1. |