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
eventsandfunctionskeys are independent dispatch tables; leaving both unset selects everything the spec declares. eventsmatches a decoded log (fullblock/logfields, nevertx.from/to/value);functionsmatches decoded calldata (txfields present immediately,tx.statusandblock.*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-rpccan satisfy botheventsandfunctionsin the same poll cycle;evm-mempoolonly ever producesfunctions, 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 wayevm-rpccan.
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 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.
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 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 |
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 (see below) | 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 |
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.