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

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 events and functions. Naming neither selects every event and function the referenced spec declares; naming either selects only what it names.
  • events matches a decoded log; functions matches decoded calldata. Both kinds compile the same way regardless of source; what differs is the raw material a source ever supplies.
  • evm-rpc can satisfy both kinds in one poll cycle. evm-subgraph can too, for whatever the deployed subgraph already indexed: interest filters only narrow GraphQL _in fetching, never matching, so a monitor for an address the mapping never stored never fires. evm-mempool only ever produces functions, 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.config is the family’s RPC, mempool, or subgraph shape, not a shared schema.

Modules

FamilyModuleRegistered inTrade-off
sourceevm-rpccrates/blockwatcher-evm/src/registry.rsWaits 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.
sourceevm-mempoolcrates/blockwatcher-evm/src/registry.rsReports 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.
sourceevm-subgraphcrates/blockwatcher-evm/src/registry.rsPolls 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.
decoderevmcrates/blockwatcher-evm/src/registry.rsCompiles 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 always 1: a log exists only inside a transaction that succeeded, so this is a constant stamped at decode time by assemble_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 on namespaces (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 no to at all.
  • tx.index, tx.status, and every block.* field: present only once the transaction is mined. A pending transaction has none of them yet, since they are TxEnvelope’s optional fields, assembled by assemble_call_fields (crates/blockwatcher-evm/src/decoder/decode.rs), and a predicate reading one before that resolves Unknown, never an error, never a fabricated value.
  • No log.* namespace at all: nothing about a function-call decode came from a log, as documented on assemble_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 ondata available to a predicateposition / delivery guarantee
events (any source)a decoded logargs.*; tx.hash/index/status(=1); no tx.from/to/value; full block.*; full log.*inherits whichever source produced the log
functions on evm-rpca decoded transaction, minedargs.*; 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-subgrapha decoded log or mined transaction the subgraph indexedsame fields as the evm-rpc rows abovechain position; at-least-once; completeness is the indexed set, not the chain
functions on evm-mempoola decoded pending transactionsame 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 onearrival counter only; not replayable across a restart; dedupe by tx.hash, not match id
events on evm-mempoolnothingnonethis 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:

KeyTypeDefaultMeaning
namestringnone (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_secretstringnone (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.
prioritystring: high or low"high"The selection tier; high endpoints are always tried before low.
rate_limitobject { "rps": u32 }noneA 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.
weightu321Ring 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:

KeyTypeDefaultMeaning
endpointsarraynone (required)The pool, per the table above. An empty array refuses: evm-rpc requires at least one endpoint.
selectionstring: 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_blocku64none (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.
confirmationsu6412How deep a block must age before it is emitted; trades delivery latency for reorg safety.
max_lag_blocksu643How far behind the pool’s most current head an endpoint may report before it is excluded from serving.
poll_interval_msu643000How often, while caught up, one eth_blockNumber head check advances the emission barrier.
logs_windowobject{ "initial": 512, "max": 2048 }How wide an eth_getLogs scan window starts and how far it may grow, per the table below.
full_block_windowu648Ceiling 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_msu6410000How often every non-open endpoint is probed for health changes, bypassing rate limits.
retry_backoff_max_msu6430000Cap 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.
receiptsstring: 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_concurrencyu644How 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_batchu6420How 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_screenbooltrueWhether 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):

KeyTypeDefaultMeaning
initialu64512Starting 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).
maxu642048How 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"
      }
    }
  ]
}
KeyTypeDefaultMeaning
endpointsarraynone (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.
selectionstring: 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_blocku64none (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.
confirmationsu640How 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_blocksu643How far behind the pool’s most current head an endpoint may report before it is excluded from serving.
poll_interval_msu643000How often, while caught up, one _meta head check advances the emission barrier.
logs_windowobject{ "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_msu6410000How often every non-open endpoint is probed for health changes, bypassing rate limits.
retry_backoff_max_msu6430000Cap 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_sizeu641000How 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:

KeyTypeDefaultMeaning
header_secretsobject, 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):

KeyTypeDefaultMeaning
initialu64512Starting 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).
maxu642048Operator 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"
    }
  ]
}
KeyTypeDefaultMeaning
ws_url_secretstringnone (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.
endpointsarraynone (required)The HTTP hydration pool, per the shared endpoint table above. An empty array refuses: evm-mempool requires at least one endpoint.
reconnect_msu641000How 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_policyobject{ "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):

KeyTypeDefaultMeaning
ping_after_msu6430000How 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_msu6410000How 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.