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

Selectors

Every entry in a monitor’s selectors list is a selector: a reference to one contract spec, an optional address restriction, and a choice of what that spec has on offer to actually decode. But naming something in a selector is only half the story: what it can produce also depends on which source module happens to be feeding its network, since evm-rpc and evm-mempool hand the decoder two very different kinds of raw material. This page walks both halves: the compile-time selection rule in crates/blockwatcher-evm/src/decoder/selector.rs, then what each source in crates/blockwatcher-evm/src/source/ actually supplies for it to work with.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    sources["Sources<br/>evm-rpc · evm-mempool"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sinks,storage,api,metrics,engine,decoder,matcher,gate dim
class sources focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • A selector’s events and functions keys are independent dispatch tables; leaving both unset selects everything the spec declares.
  • events matches a decoded log (full block/log fields, never tx.from/to/value); functions matches decoded calldata (tx fields present immediately, tx.status and block.* only once mined).
  • Both selector kinds compile the same way regardless of source; what differs is only what raw material a given source ever supplies to decode.
  • evm-rpc can satisfy both events and functions in the same poll cycle; evm-mempool only ever produces functions, and only unmined fields.
  • evm-mempool’s cursor is a per-run arrival counter, not a chain position, so it cannot support at-least-once delivery or history fetches the way evm-rpc can.

Which combination of selector kind and source actually produces something for a predicate to see comes down to one decision tree:

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-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-mempool"| fnpending["decodes a pending tx<br/>no tx.status, no block.*"]

    evlog --> pred["predicate evaluates"]
    fnmined --> pred
    fnpending --> pred

events and functions: one presence rule

A selector body carries optional keys (events, functions, 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). “Present but empty” and “never written” would otherwise both have to mean something, and letting the empty spelling default to “select nothing” would compile an entry nobody could ever notice never fires.

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.

What each kind decodes, and what it hands the predicate

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.

The source: what raw material even reaches the selector

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).

evm-mempool, by contrast, 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).

The position problem: why evm-mempool can’t 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 other shipped source can promise it, this one cannot.

Comparison table

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
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 one (see below)arrival counter only; not replayable across a restart; dedupe by tx.hash, not match id
events on evm-mempoolnothingnonethis source never produces a log

Predicates covers how a predicate reads any of this data once it’s decoded; The pipeline covers where decoding and matching actually run and what’s per-network versus shared.